Compare commits

...

48 Commits

Author SHA1 Message Date
Lucas Smith de134afba1 v2.8.1 2026-03-17 01:30:28 +11:00
Ephraim Duncan 36bbd97514 feat: add organisation template type (#2611) 2026-03-17 01:29:34 +11:00
Ephraim Duncan 943a0b50e3 perf: parallelize async operations in duplicateEnvelope (#2619) 2026-03-16 02:34:08 +00:00
Ephraim Duncan 6ef501c9f2 perf: parallelize getTeamSettings and getEditorEnvelopeById (#2617) 2026-03-16 11:13:39 +11:00
Ephraim Duncan ac09a48eaa perf: parallelize independent async operations in createEnvelope (#2618) 2026-03-16 11:13:36 +11:00
Ephraim Duncan 70fb834a6a feat: add more webhook events (#2125) 2026-03-15 19:47:52 +11:00
Ephraim Duncan 66e357c9b3 feat: add email domain restriction for signups (#2266)
Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2026-03-14 16:32:34 +11:00
Ted Liang 3106fd7483 fix: exclude native modules from Vite dependency optimization (#2615) 2026-03-14 11:51:00 +11:00
Catalin Pit 32c54e1245 fix: hide name/email in embed signing when provided via prop (#2600)
## Description

When signing via embed, recipient name and email provided through the
embed context were ignored if the DB recipient record had empty values.

This fix adds:
- the signing context's fullName and email as fallbacks in the recipient
payload
- keeps the form in sync with values instead of defaultValues
- ensures the override payload is sent even when the form is hidden
2026-03-13 21:59:10 +11:00
Ted Liang 83fbc70a1c refactor: avoid recipient color duplication (#2355) 2026-03-13 15:52:15 +11:00
Lucas Smith 1ee6ec87a2 chore: add translations (#2614) 2026-03-13 15:22:20 +11:00
Lucas Smith 6b1b1d0417 fix: improve webhook execution (#2608)
Webhook URLs were being fetched without validating whether they
resolved to private/loopback addresses, exposing the server to SSRF.

Current SSRF is best effort and fail open, you should never host
services that
you cant risk exposure of.

This extracts webhook execution into a shared module that validates
URLs against private IP ranges (including DNS resolution), enforces
timeouts, and disables redirect following. The resend route now
queues through the job system instead of calling fetch inline.
2026-03-13 15:02:09 +11:00
Lucas Smith 9f680c7a61 perf: set global prisma transaction timeouts and reduce transaction scope (#2607)
Configure default transaction options (5s maxWait, 10s timeout) on the
PrismaClient instead of per-transaction overrides. Move side effects
like email sending, webhook triggers, and job dispatches out of
$transaction blocks to avoid holding database connections open during
network I/O.

Also extracts the direct template email into a background job and fixes
a bug where prisma was used instead of tx inside a transaction.
2026-03-13 14:51:53 +11:00
github-actions[bot] 76d96d2f65 chore: extract translations (#2583) 2026-03-13 14:50:48 +11:00
David Nguyen 2f2b5dd232 feat: allow creating embeds in folder (#2612)
## Description

Allow passing in a `folderId` when creating an embedded envelope 

## Embed repo changes here

https://github.com/documenso/embeds/pull/69/changes
2026-03-13 14:50:14 +11:00
David Nguyen 8d97f1dcfa fix: resolve error flash on page refresh (#2606) 2026-03-13 12:37:30 +11:00
David Nguyen e67e19358a fix: add hipaa flag (#2603) 2026-03-13 12:06:10 +11:00
Timur Ercan 364537e8fe chore: update hipaa status in docs (#2599) 2026-03-13 12:00:05 +11:00
Joshua Sharp 4751c9cecc fix: template description overflow (#2605) 2026-03-12 18:15:21 +11:00
VIVEK TIWARI a5fd814fbc fix: handle invalid qr share tokens without 500 (#2597) 2026-03-12 13:46:17 +11:00
Ephraim Duncan 1d2c781a6d docs: add organisation ownership transfer guide (#2601) 2026-03-12 13:39:37 +11:00
Lucas Smith 03ca3971a0 perf: upgrade @libpdf/core to 0.3.3 and deduplicate font registration (#2598)
Upgrade @libpdf/core from 0.2.12 to 0.3.3, which includes:
- WebCrypto SHA-256 replacing pure-JS @noble/hashes (10x signing
speedup)
- Iterative collectReachableRefs (fixes stack overflow on large PDFs)
- Iterative Math.max helpers in xref writer (fixes remaining stack
overflow)

Extract duplicated FontLibrary.use() calls from render-certificate,
render-audit-logs, and insert-field-in-pdf-v2 into a shared
ensureFontLibrary() helper with has() guards so fonts are only
registered once per process.
2026-03-11 20:23:18 +11:00
Lucas Smith 5ea4060fd7 v2.8.0 2026-03-10 21:43:01 +11:00
Lucas Smith af346b179c feat: add recipient role editing and audit log PDF download in admin (#2594)
- Allow admins to update recipient role from document detail page
- Add download button to export audit logs as PDF
- Display recipient status details in accordion
- Add LocalTime component with hover popover for timestamps
2026-03-10 21:41:46 +11:00
Catalin Pit ab69ee627b fix: include extra recipient info in missing fields error msg (#2590) 2026-03-10 12:17:24 +11:00
Lucas Smith 4daec44550 fix: move window.__ENV__ script before client bundle to prevent stale fallback (#2592) 2026-03-10 12:15:15 +11:00
Ted Liang 11eb4dd2cd fix: security CVE-2026-29045 (#2589) 2026-03-09 16:46:11 +11:00
Lucas Smith cc71c7d9ba fix: add cmaps (#2588) 2026-03-09 14:07:13 +11:00
Lucas Smith f82bf97480 fix: only use embed hash name/email as fallback when recipient values are blank (#2586)
For document signing embeds, the hash-provided name and email should
only
be used when the recipient doesn't already have values set. For template
signing, the hash values are always allowed.

Also makes the email input editable in V1 embeds when the recipient has
no email, matching V2 behavior.

Ref: documenso/embeds#53
2026-03-09 13:30:27 +11:00
Lucas Smith 0e20d364ef fix: opt findDocumentsInternal query out of batch fetching (#2585) 2026-03-09 12:47:59 +11:00
David Nguyen ef57c8448a fix: dropdown fields (#2584) 2026-03-09 12:19:20 +11:00
Lucas Smith eaaf8f9e63 chore: add translations (#2582) 2026-03-09 11:56:17 +11:00
David Nguyen 58f0c98038 chore: add embed envelope docs (#2576) 2026-03-09 11:50:13 +11:00
Catalin Pit da7b5d12f8 fix: make signing page left-hand sidebar collapsible (#2541) 2026-03-09 11:45:28 +11:00
github-actions[bot] 7cfe876762 chore: extract translations (#2577) 2026-03-09 11:39:37 +11:00
Ephraim Duncan 15399cbe8e feat: auto-disable telemetry when license key is configured (#2562) 2026-03-09 11:24:24 +11:00
Catalin Pit c4754553c9 feat: implement template search functionality (#2376)
- Added  function to handle template searches based on user input
- Introduced in the TRPC router to facilitate authenticated template
searches
- Updated to include template search results alongside document search
results
- Enhanced query handling by enabling searches only when the input is
valid
- Created corresponding Zod schemas for request and response validation
in
2026-03-09 10:44:51 +11:00
David Nguyen 6c8726b58c fix: performance improvements (#2581) 2026-03-09 10:22:57 +11:00
Lucas Smith abd031b58b chore: add translations (#2575) 2026-03-06 16:10:54 +11:00
github-actions[bot] 1ff8680c32 chore: extract translations (#2566) 2026-03-06 14:15:37 +11:00
David Nguyen 7ea664214a feat: add embedded envelopes (#2564)
## Description

Add envelopes V2 embedded support

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-06 14:11:27 +11:00
Ephraim Duncan 7e2cbe46c0 fix: show current month data and add caching (#2573)
### Summary

- Add Cache-Control headers to all route responses (1h s-maxage, 2h
stale-while-revalidate)
- Append current month to chart data so graphs stay up-to-date
(cumulative carries forward, else zero)
- Remove `.limit(12)` from growth queries for full history
- Pass isCumulative flag through addZeroMonth
- Deduplicate TransformedData type, remove transformRepoStats
2026-03-06 13:30:31 +11:00
Konrad c63b4ca3cc fix(i18n): mark dropdown and radio placeholder for translation (#2537) 2026-03-06 13:05:03 +11:00
David Nguyen 6faa01d384 feat: add pdf image renderer (#2554)
## Description

Replace the PDF renderer with an custom image renderer.

This allows us to remove the "react-pdf" dependency and allows us to use
a virtual list to improve performance.
2026-03-06 12:39:03 +11:00
Lucas Smith 0ce909a298 refactor: find envelopes (#2557) 2026-03-06 12:38:40 +11:00
Lucas Smith 7f271379b9 fix: upgrade @libpdf/core (#2572) 2026-03-06 10:08:58 +11:00
Lucas Smith 406e77e4be chore: add translations (#2570) 2026-03-05 17:33:36 +11:00
Lucas Smith bff360b084 fix: upgrade @libpdf/core (#2569) 2026-03-05 15:34:40 +11:00
369 changed files with 28329 additions and 8107 deletions
@@ -0,0 +1,113 @@
---
date: 2026-03-07
title: Search Query Optimization
---
## Problem
The `searchDocumentsWithKeyword` and `searchTemplatesWithKeyword` functions generate a single massive Prisma `findMany` with 7 OR branches. This produces a SQL query that:
- Joins `Team` twice (aliased j3 and j8) for the two team-access branches
- Embeds 4-level deep EXISTS subqueries (`TeamGroup -> OrganisationGroup -> OrganisationGroupMember -> OrganisationMember`) for each team branch
- Uses `ILIKE` across multiple columns with no way for Postgres to use indexes effectively across the OR
- Includes `recipients: true` on the result even though only a small subset of fields are needed
- Fetches all matching rows then filters visibility **in application code**
With 1,000 documents seeded under `medium-account@documenso.com`, this query is noticeably slow.
---
## Option A: Pre-resolve team IDs, keep Prisma
**Change:** Before the envelope query, resolve the user's accessible team IDs in a single query:
```ts
const teamIds = await prisma.teamGroup
.findMany({
where: {
organisationGroup: {
organisationGroupMembers: {
some: { organisationMember: { userId } },
},
},
},
select: { teamId: true },
})
.then((rows) => [...new Set(rows.map((r) => r.teamId))]);
```
Then replace `team: buildTeamWhereQuery(...)` with `teamId: { in: teamIds }` in the envelope query.
**Benefits:**
- Eliminates the duplicated 4-level deep join chain from the envelope query
- The team ID resolution is a simple indexed lookup (runs once, not twice)
- Minimal code change -- still Prisma, same structure
- Can also pre-resolve team roles to move visibility filtering into the WHERE clause
**Drawbacks:**
- Still a single large OR query with ILIKE branches
- Prisma still generates suboptimal SQL for the remaining OR conditions
---
## Option B: Kysely rewrite with pre-resolved teams
**Change:** Rewrite using Kysely (already set up in codebase as `kyselyPrisma.$kysely`). Follow the pattern in `find-envelopes.ts` -- use Kysely for filtering/ID fetching, then Prisma for hydration.
Structure as a UNION of targeted queries instead of a single OR:
```
Query 1: owned docs matching title/externalId (simple indexed lookup)
Query 2: docs where user is recipient matching title (EXISTS on Recipient)
Query 3: team docs matching title/externalId (using pre-resolved teamIds)
UNION ALL -> deduplicate -> ORDER BY createdAt DESC -> LIMIT 20
```
Then hydrate the 20 IDs with Prisma for the include data.
**Benefits:**
- Each sub-query is simple and independently optimizable by Postgres
- UNION eliminates the massive OR which forces bad query plans
- Kysely gives control over exact SQL structure
- Only hydrate the final 20 results (not all matches)
- Follows existing `find-envelopes.ts` pattern -- not a new paradigm
**Drawbacks:**
- More code than Option A
- Two query layers (Kysely for IDs, Prisma for hydration)
---
## Option C: Hybrid -- pre-resolve teams + simplify Prisma OR
**Change:** Pre-resolve team IDs (like Option A), but also restructure the Prisma query to reduce OR branches:
- Merge "owned + title" and "owned + externalId" and "owned + recipient email" into a single owned-docs branch with nested OR
- Merge "team + title" and "team + externalId" into a single team-docs branch
- Keep "recipient inbox" branches separate
This reduces from 7 OR branches to ~3-4, with simpler conditions in each.
**Benefits:**
- Simpler than Kysely rewrite
- Fewer OR branches = better query plan
- Pre-resolved team IDs eliminate the deep joins
- Still pure Prisma
**Drawbacks:**
- Postgres still has to handle OR across different access patterns in one query
- Less control over SQL than Kysely
---
## Recommendation
**Option B (Kysely)** is the strongest choice. The codebase already uses this exact pattern for `find-envelopes.ts` which solves the same class of problem. The UNION approach gives Postgres the best chance at using indexes per sub-query. Pre-resolving team IDs is a prerequisite for all options and is trivially cheap.
The template search query has the same structure and should get the same treatment.
@@ -0,0 +1,337 @@
---
name: create-documentation
description: Generate markdown documentation for a module or feature
---
You are creating proper markdown documentation for a feature or guide in the Documenso documentation site.
**Read [WRITING_STYLE.md](../../../WRITING_STYLE.md) first** for tone, formatting conventions, and anti-patterns to avoid.
## Your Task
1. **Identify the scope** - Based on the conversation context, determine what feature or topic needs documentation. Ask the user if unclear.
2. **Identify the audience** - Is this for Users, Developers, or Self-Hosters?
3. **Read the source code** - Understand the feature, API, or configuration being documented.
4. **Read existing docs** - Check `apps/docs/content/docs/` for documentation to update.
5. **Write comprehensive documentation** - Create or update MDX docs following the patterns below.
6. **Update navigation** - Add to the relevant `meta.json` if creating a new page.
## Documentation Framework
This project uses [Fumadocs](https://fumadocs.dev). All documentation lives in `apps/docs/content/docs/` as MDX files. The docs app is a Next.js app at `apps/docs/`.
## Documentation Structure
```
apps/docs/content/docs/
├── index.mdx # Landing page with audience navigation
├── meta.json # Root navigation: guides + resources
├── users/ # Application usage guides
│ ├── meta.json # { "root": true, "pages": [...] }
│ ├── getting-started/ # Account creation, first document
│ ├── documents/ # Upload, recipients, fields, send
│ │ └── advanced/ # AI detection, visibility, placeholders
│ ├── templates/ # Create and use templates
│ ├── organisations/ # Overview, members, groups, SSO, billing
│ │ ├── single-sign-on/
│ │ └── preferences/
│ └── settings/ # Profile, security, API tokens
├── developers/ # API and integration docs
│ ├── meta.json # { "root": true, "pages": [...] }
│ ├── getting-started/ # Authentication, first API call
│ ├── api/ # Documents, recipients, fields, templates, teams
│ ├── webhooks/ # Setup, events, verification
│ ├── embedding/ # Authoring, direct links, CSS vars, SDKs
│ │ └── sdks/ # React, Vue, Svelte, Solid, Preact, Angular
│ ├── examples/ # Common workflows
│ ├── local-development/ # Quickstart, manual, translations
│ └── contributing/ # Contributing translations
├── self-hosting/ # Self-hosting documentation
│ ├── meta.json # { "root": true, "pages": [...] }
│ ├── getting-started/ # Quick start, requirements, tips
│ ├── deployment/ # Docker, docker-compose, Kubernetes, Railway
│ ├── configuration/ # Environment, database, email, storage
│ │ ├── signing-certificate/ # Local, Google Cloud HSM, timestamp
│ │ └── advanced/ # OAuth providers, AI features
│ └── maintenance/ # Upgrades, backups, troubleshooting
├── concepts/ # Shared across audiences
│ └── ... # Document lifecycle, field types, signing
├── compliance/ # eSign, GDPR, standards, certifications
└── policies/ # Terms, privacy, security, licenses
```
### Where to Put Documentation
| Type | Location | When to use |
| ------------------- | ------------------------------------------------ | -------------------------------------------------- |
| **User Guide** | `apps/docs/content/docs/users/<section>/` | UI workflows for using the Documenso web app |
| **Developer Guide** | `apps/docs/content/docs/developers/<section>/` | API reference, SDK guides, webhooks, embedding |
| **Self-Hosting** | `apps/docs/content/docs/self-hosting/<section>/` | Deployment, configuration, environment variables |
| **Concept** | `apps/docs/content/docs/concepts/` | Cross-audience concepts (document lifecycle, etc.) |
| **Compliance** | `apps/docs/content/docs/compliance/` | Legal and regulatory documentation |
### Navigation (meta.json)
Each directory has a `meta.json` controlling navigation order:
```json
{
"title": "Section Title",
"pages": ["getting-started", "documents", "templates"]
}
```
Top-level audience sections use `"root": true`:
```json
{
"title": "Users",
"description": "Send and sign documents",
"root": true,
"pages": ["getting-started", "documents", "templates", "organisations", "settings"]
}
```
Root `meta.json` uses `---Label---` for section dividers:
```json
{
"title": "Documentation",
"pages": [
"---Guides---",
"users",
"developers",
"self-hosting",
"---Resources---",
"concepts",
"compliance",
"policies"
]
}
```
## MDX File Format
### Frontmatter
Every page needs frontmatter:
```yaml
---
title: Upload Documents
description: Upload documents to Documenso to prepare them for signing. Covers supported formats, file size limits, and upload methods.
---
```
### Fumadocs Components
Import components at the top of the file (after frontmatter):
```mdx
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
;
```
Callouts (use sparingly for warnings, beta features, security):
```mdx
<Callout type="info">Informational note about behavior.</Callout>
<Callout type="warn">Warning about potential issues or breaking changes.</Callout>
<Callout type="error">Critical warning about data loss or security.</Callout>
```
Steps (for sequential UI instructions):
```mdx
{/* prettier-ignore */}
<Steps>
<Step>
### Step title
Step description.
</Step>
<Step>
### Next step
Next description.
</Step>
</Steps>
```
Tabs (for multiple approaches or platforms):
````mdx
<Tabs items={['cURL', 'JavaScript', 'Python']}>
<Tab value="cURL">```bash curl -X POST ... ```</Tab>
<Tab value="JavaScript">```typescript const response = await fetch(...) ```</Tab>
</Tabs>
````
## Page Structure by Audience
### User Documentation
```mdx
---
title: Feature Name
description: Brief description for SEO and previews.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
## Limitations
| Limitation | Value |
| ----------------- | -------- |
| Supported format | PDF only |
| Maximum file size | 50MB |
## How to Do the Thing
{/* prettier-ignore */}
<Steps>
<Step>
### Navigate to the page
Open **Settings > Feature**.
</Step>
<Step>
### Configure the setting
Fill in the required fields and click **Save**.
</Step>
</Steps>
---
## See Also
- [Related Guide](/docs/users/related)
```
### Developer Documentation
````mdx
---
title: Documents API
description: Create, manage, and send documents for signing via the API.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<Callout type="warn">
This guide may not reflect the latest endpoints. For an always up-to-date reference, see the
[OpenAPI Reference](https://openapi.documenso.com).
</Callout>
## Overview
Brief description of the resource and what you can do with it.
## Resource Object
| Property | Type | Description |
| -------- | ------ | ----------------- |
| `id` | string | Unique identifier |
| `status` | string | Current status |
## Create a Resource
```typescript
const response = await fetch('https://app.documenso.com/api/v2/document', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_API_TOKEN',
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'Service Agreement',
}),
});
```
````
---
## See Also
- [Related Guide](/docs/developers/related)
````
### Self-Hosting Documentation
```mdx
---
title: Environment Variables
description: Complete reference for all environment variables used to configure Documenso.
---
## Required Variables
| Variable | Description |
| ------------------ | ------------------------------------------------ |
| `NEXTAUTH_SECRET` | Secret key for session encryption (min 32 chars) |
| `DATABASE_URL` | PostgreSQL connection URL |
---
## Optional Variables
| Variable | Default | Description |
| -------------- | ------- | ---------------------- |
| `PORT` | `3000` | Port the server runs on |
---
## See Also
- [Database Configuration](/docs/self-hosting/configuration/database)
````
## Documentation Audiences
Tailor content to the audience:
- **User docs**: Focus on UI workflows, bold UI elements (**Settings**, **Save**), use `>` for navigation paths (**Settings > Team > Members**), number sequential steps, no code required
- **Developer docs**: API/SDK examples, authentication, webhooks, code samples in TypeScript, link to OpenAPI reference
- **Self-hosting docs**: Deployment guides, environment variables, Docker/non-Docker approaches, system requirements, troubleshooting
## Guidelines
See [WRITING_STYLE.md](../../../WRITING_STYLE.md) for complete guidelines. Key points:
- **Tone**: Direct, second-person, no emojis, no excessive personality
- **Examples**: Progressive complexity, all must be valid TypeScript
- **Tables**: Use Sharp-style nested parameter tables for API docs
- **Callouts**: Use sparingly for warnings, beta features, security
- **Cross-references**: Link related docs, add "See Also" sections
- **Navigation**: Update `meta.json` when adding new pages
- **Limitations**: Explicitly list what is NOT supported
- **Images**: Use `.webp` format, store in `apps/docs/public/`
## Process
1. **Identify the audience** - Users, Developers, or Self-Hosters?
2. **Explore the code** - Read source files to understand the feature
3. **Check existing docs** - Look in `apps/docs/content/docs/` for related pages
4. **Draft the structure** - Outline sections before writing
5. **Write content** - Fill in each section following audience-specific patterns
6. **Update navigation** - Add to relevant `meta.json` if creating a new page
7. **Add cross-references** - Link from related docs, add "See Also" section
## Begin
Analyze the conversation context to determine the documentation scope, read the relevant source code, and create comprehensive MDX documentation in `apps/docs/content/docs/`.
@@ -0,0 +1,371 @@
---
name: envelope-editor-v2-e2e
description: Writing and maintaining Playwright E2E tests for the Envelope Editor V2. Use when the user needs to create, modify, debug, or extend E2E tests in packages/app-tests/e2e/envelope-editor-v2/. Triggers include requests to "write an e2e test", "add a test for the envelope editor", "test envelope settings/recipients/fields/items/attachments", "fix a failing envelope test", or any task involving Playwright tests for the envelope editor feature.
---
# Envelope Editor V2 E2E Tests
## Overview
The Envelope Editor V2 E2E test suite lives in `packages/app-tests/e2e/envelope-editor-v2/`. Each test file covers a distinct feature area of the envelope editor and follows a strict architectural pattern that tests the **same flow** across four surfaces:
1. **Document** (`documents/<id>`) - Native document editor
2. **Template** (`templates/<id>`) - Native template editor
3. **Embedded Create** (`/embed/v2/authoring/envelope/create`) - Embedded editor creating a new envelope
4. **Embedded Edit** (`/embed/v2/authoring/envelope/edit/<id>`) - Embedded editor updating an existing envelope
## Project Structure
```
packages/app-tests/
e2e/
envelope-editor-v2/
envelope-attachments.spec.ts # Attachment CRUD
envelope-fields.spec.ts # Field placement on PDF canvas
envelope-items.spec.ts # PDF document item CRUD
envelope-recipients.spec.ts # Recipient management
envelope-settings.spec.ts # Settings dialog
fixtures/
authentication.ts # apiSignin, apiSignout
documents.ts # Document tab helpers
envelope-editor.ts # Core fixture: surface openers + locator/action helpers
generic.ts # Toast assertions, text visibility
signature.ts # Signature pad helpers
playwright.config.ts # Test configuration
```
## Core Abstraction: `TEnvelopeEditorSurface`
Every test revolves around the `TEnvelopeEditorSurface` type from `fixtures/envelope-editor.ts`. This is the central abstraction that normalizes differences between the four surfaces:
```typescript
type TEnvelopeEditorSurface = {
root: Page; // The Playwright page
isEmbedded: boolean; // true for embed surfaces
envelopeId?: string; // Set for document/template/embed-edit, undefined for embed-create
envelopeType: 'DOCUMENT' | 'TEMPLATE';
userId: number; // Seeded user ID
userEmail: string; // Seeded user email
userName: string; // Seeded user name
teamId: number; // Seeded team ID
};
```
### Surface Openers (from `fixtures/envelope-editor.ts`)
```typescript
// Native surfaces - seed user + document/template, sign in, navigate
const surface = await openDocumentEnvelopeEditor(page);
const surface = await openTemplateEnvelopeEditor(page);
// Embedded surfaces - seed user, create API token, get presign token, navigate
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT' | 'TEMPLATE',
mode?: 'create' | 'edit', // default: 'create'
tokenNamePrefix?: string, // for unique API token names
externalId?: string, // optional external ID in hash
features?: EmbeddedEditorConfig, // feature flags
});
```
## Test Architecture Pattern
Every test file follows this structure, with four `test.describe` blocks grouping tests by editor surface:
### 1. Imports
```typescript
import { type Page, expect, test } from '@playwright/test';
// Prisma enums if needed for DB assertions
import { SomePrismaEnum } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface, // Import needed helpers from the fixture
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope, // ... other helpers
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
```
### 2. Type definitions and constants
```typescript
type FlowResult = {
externalId: string;
// ... other data needed for DB assertions
};
const TEST_VALUES = {
// Centralized test data constants
};
```
### 3. Local helper functions
```typescript
// Common: open settings and set external ID for DB lookup
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
```
### 4. The flow function
A single `runXxxFlow` function that works across ALL surfaces. It handles embedded vs non-embedded differences internally:
```typescript
const runMyFeatureFlow = async (surface: TEnvelopeEditorSurface): Promise<FlowResult> => {
const externalId = `e2e-feature-${nanoid()}`;
// For embedded create, may need to add a PDF first
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'embedded-feature.pdf');
}
await updateExternalId(surface, externalId);
// Handle embedded vs native differences
if (surface.isEmbedded) {
// No "Add Myself" button in embedded mode
await setRecipientEmail(surface.root, 0, 'embedded@example.com');
} else {
await clickAddMyselfButton(surface.root);
}
// ... perform feature-specific actions ...
// Navigate away and back to verify UI persistence
await clickEnvelopeEditorStep(surface.root, 'addFields');
await clickEnvelopeEditorStep(surface.root, 'upload');
// ... assert UI state after navigation ...
return { externalId /* ... */ };
};
```
### 5. Database assertion function
Uses Prisma directly to verify data was persisted correctly:
```typescript
const assertFeaturePersistedInDatabase = async ({
surface,
externalId,
// ... expected values
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
// ...
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
// Include related data as needed
documentMeta: true,
recipients: true,
fields: true,
envelopeAttachments: true,
},
orderBy: { createdAt: 'desc' },
});
// Assert expected values
expect(envelope.someField).toBe(expectedValue);
};
```
### 6. The four `test.describe` blocks
Tests are organized into four `test.describe` blocks, one per editor surface. Each describe block contains the tests relevant to that surface. This structure allows adding multiple tests per surface while keeping them grouped:
```typescript
test.describe('document editor', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runMyFeatureFlow(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional document-editor-specific tests here...
});
test.describe('template editor', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runMyFeatureFlow(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional template-editor-specific tests here...
});
test.describe('embedded create', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-feature',
});
const result = await runMyFeatureFlow(surface);
// IMPORTANT: Must persist before DB assertions for embedded
await persistEmbeddedEnvelope(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional embedded-create-specific tests here...
});
test.describe('embedded edit', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-feature',
});
const result = await runMyFeatureFlow(surface);
// IMPORTANT: Must persist before DB assertions for embedded
await persistEmbeddedEnvelope(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional embedded-edit-specific tests here...
});
```
When a test only applies to specific surfaces (e.g., a document-only action like "send document"), only include it in the relevant describe block(s). Not every describe block needs the same tests -- the structure groups tests by surface, not by requiring symmetry.
## Key Differences Between Surfaces
| Behavior | Document/Template | Embedded Create | Embedded Edit |
| -------------------------- | -------------------------- | ----------------------------------------- | ----------------------------------------- |
| User seeding | Seed + sign in | Seed + API token | Seed + API token + seed envelope |
| "Add Myself" button | Available | Not available | Not available |
| Toast on settings update | Yes (`'Envelope updated'`) | No | No |
| PDF already attached | Yes (1 item) | No (0 items, must upload) | Yes (1 item) |
| Delete confirmation dialog | Yes (`'Delete'` button) | No (immediate) | No (immediate) |
| DB persistence timing | Immediate (autosaved) | After `persistEmbeddedEnvelope()` | After `persistEmbeddedEnvelope()` |
| Persist button label | N/A | `'Create Document'` / `'Create Template'` | `'Update Document'` / `'Update Template'` |
## Available Fixture Helpers
### From `fixtures/envelope-editor.ts`
**Locator helpers** (return Playwright Locators):
- `getEnvelopeEditorSettingsTrigger(root)` - Settings gear button
- `getEnvelopeItemTitleInputs(root)` - Title inputs for envelope items
- `getEnvelopeItemDragHandles(root)` - Drag handles for reordering items
- `getEnvelopeItemRemoveButtons(root)` - Remove buttons for items
- `getEnvelopeItemDropzoneInput(root)` - File input for PDF upload
- `getRecipientEmailInputs(root)` - Email inputs for recipients
- `getRecipientNameInputs(root)` - Name inputs for recipients
- `getRecipientRows(root)` - Full recipient row fieldsets
- `getRecipientRemoveButtons(root)` - Remove buttons for recipients
- `getSigningOrderInputs(root)` - Signing order number inputs
**Action helpers**:
- `addEnvelopeItemPdf(root, fileName?)` - Upload a PDF to the dropzone
- `clickEnvelopeEditorStep(root, stepId)` - Navigate to a step: `'upload'`, `'addFields'`, `'preview'`
- `clickAddMyselfButton(root)` - Click "Add Myself" (native only)
- `clickAddSignerButton(root)` - Click "Add Signer"
- `setRecipientEmail(root, index, email)` - Fill recipient email
- `setRecipientName(root, index, name)` - Fill recipient name
- `setRecipientRole(root, index, roleLabel)` - Set role via combobox
- `assertRecipientRole(root, index, roleLabel)` - Assert role value
- `toggleSigningOrder(root, enabled)` - Toggle signing order switch
- `toggleAllowDictateSigners(root, enabled)` - Toggle dictate signers switch
- `setSigningOrderValue(root, index, value)` - Set signing order number
- `persistEmbeddedEnvelope(surface)` - Click Create/Update button for embedded flows
### From `fixtures/generic.ts`
- `expectTextToBeVisible(page, text)` - Assert text visible on page
- `expectTextToNotBeVisible(page, text)` - Assert text not visible
- `expectToastTextToBeVisible(page, text)` - Assert toast message visible
## External ID Pattern
Every test uses an `externalId` (e.g., `e2e-feature-${nanoid()}`) set via the settings dialog. This unique ID is then used in Prisma queries to reliably locate the envelope in the database for assertions. This is critical because multiple tests run in parallel.
## Running Tests
```bash
# Run all envelope editor tests
npm run test:dev -w @documenso/app-tests -- --grep "Envelope Editor V2"
# Run a specific test file
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-recipients.spec.ts
# Run with UI
npm run test-ui:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/
# Run specific test by name
npm run test:dev -w @documenso/app-tests -- --grep "documents/<id>: add myself"
```
## Checklist When Writing a New Test
1. Create the spec file in `packages/app-tests/e2e/envelope-editor-v2/`
2. Import `TEnvelopeEditorSurface` and the three opener functions
3. Import `persistEmbeddedEnvelope` if you need DB assertions for embedded flows
4. Define a `FlowResult` type for data passed between flow and assertion
5. Define `TEST_VALUES` constants for test data
6. Write `updateExternalId` helper (or reuse the pattern)
7. Write the `runXxxFlow` function handling embedded vs native differences
8. Write the `assertXxxPersistedInDatabase` function using Prisma
9. Create four `test.describe` blocks: `'document editor'`, `'template editor'`, `'embedded create'`, `'embedded edit'`
10. Place tests inside the appropriate describe block for each surface
11. For embedded create tests, add a PDF via `addEnvelopeItemPdf` before the flow
12. For embedded tests, call `persistEmbeddedEnvelope(surface)` before DB assertions
13. Use `surface.isEmbedded` to branch on behavioral differences (toasts, "Add Myself", etc.)
## Common Pitfalls
- **Missing `persistEmbeddedEnvelope`**: Embedded flows don't autosave. You MUST call this before any DB assertions.
- **PDF required for embedded create**: Embedded create starts with 0 items. Upload a PDF before navigating to fields.
- **Toast assertions in embedded**: Don't assert toasts for settings updates in embedded mode (they don't appear).
- **Parallel test isolation**: Always use a unique `externalId` via `nanoid()` so parallel tests don't collide.
- **Navigation verification**: Navigate away from and back to the current step to verify UI state persistence (the editor may re-render).
- **Delete confirmation**: Native surfaces show a confirmation dialog for item deletion; embedded surfaces delete immediately.
+2
View File
@@ -153,6 +153,8 @@ NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
+1
View File
@@ -63,6 +63,7 @@ CLAUDE.md
# scripts
scripts/output*
scripts/bench-*
# license
.documenso-license.json
@@ -0,0 +1,27 @@
---
description: Generate markdown documentation for a module or feature
argument-hint: <topic-or-feature>
---
You are generating documentation for the Documenso project.
## Your Task
Load and follow the skill at `.agents/skills/create-documentation/SKILL.md`. It contains the complete instructions for writing documentation including:
- Documentation structure and file locations
- MDX format and Fumadocs components
- Audience-specific patterns (Users, Developers, Self-Hosters)
- Navigation (`meta.json`) updates
- Writing style guidelines
## Context
The topic or feature to document is: `$ARGUMENTS`
## Begin
1. **Read the skill** at `.agents/skills/create-documentation/SKILL.md`
2. **Read WRITING_STYLE.md** for tone and formatting conventions
3. **Follow the skill instructions** to create comprehensive documentation
4. **Use TodoWrite** to track your progress throughout
+8 -60
View File
@@ -7,69 +7,17 @@ You are creating a new justification file in the `.agents/justifications/` direc
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the justification content
3. **Create the file** - Use the create-justification script to generate the file
Load and follow the skill at `.agents/skills/create-justification/SKILL.md`. It contains the complete instructions for creating justification files including:
## Usage
- Unique three-word ID generation
- Frontmatter format with date and title
- Script usage (`scripts/create-justification.ts`)
The script will automatically:
- Generate a unique three-word ID (e.g., `swift-emerald-river`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/justifications/`
## Context
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-justification.ts "$ARGUMENTS" "Your justification content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-justification.ts "$ARGUMENTS" << HEREDOC
Your multi-line
justification content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-justification.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Justification Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `architecture-decision``Architecture Decision`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `tech-stack-choice`, `api-design-rationale`)
- Include clear reasoning and context for the decision
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
The justification slug and optional content: `$ARGUMENTS`
## Begin
Create a justification file using the slug from `$ARGUMENTS` and appropriate content documenting the reasoning or justification.
1. **Read the skill** at `.agents/skills/create-justification/SKILL.md`
2. **Create the justification file** using the slug from `$ARGUMENTS` and appropriate content documenting the reasoning or justification
+8 -61
View File
@@ -7,70 +7,17 @@ You are creating a new plan file in the `.agents/plans/` directory.
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the plan content
3. **Create the file** - Use the create-plan script to generate the file
Load and follow the skill at `.agents/skills/create-plan/SKILL.md`. It contains the complete instructions for creating plan files including:
## Usage
- Unique three-word ID generation
- Frontmatter format with date and title
- Script usage (`scripts/create-plan.ts`)
The script will automatically:
## Context
- Generate a unique three-word ID (e.g., `happy-blue-moon`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/plans/`
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-plan.ts "$ARGUMENTS" "Your plan content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-plan.ts "$ARGUMENTS" << HEREDOC
Your multi-line
plan content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-plan.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Plan Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `my-feature``My Feature`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `user-authentication`, `api-integration`)
- Include clear, actionable plan content
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
The plan slug and optional content: `$ARGUMENTS`
## Begin
Create a plan file using the slug from `$ARGUMENTS` and appropriate content for the planning task.
1. **Read the skill** at `.agents/skills/create-plan/SKILL.md`
2. **Create the plan file** using the slug from `$ARGUMENTS` and appropriate content
+8 -60
View File
@@ -7,69 +7,17 @@ You are creating a new scratch file in the `.agents/scratches/` directory.
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the scratch content
3. **Create the file** - Use the create-scratch script to generate the file
Load and follow the skill at `.agents/skills/create-scratch/SKILL.md`. It contains the complete instructions for creating scratch files including:
## Usage
- Unique three-word ID generation
- Frontmatter format with date and title
- Script usage (`scripts/create-scratch.ts`)
The script will automatically:
- Generate a unique three-word ID (e.g., `calm-teal-cloud`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/scratches/`
## Context
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-scratch.ts "$ARGUMENTS" "Your scratch content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-scratch.ts "$ARGUMENTS" << HEREDOC
Your multi-line
scratch content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-scratch.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Scratch Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `quick-notes``Quick Notes`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `exploration-ideas`, `temporary-notes`)
- Scratch files are for temporary notes, explorations, or ideas
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
The scratch slug and optional content: `$ARGUMENTS`
## Begin
Create a scratch file using the slug from `$ARGUMENTS` and appropriate content for notes or exploration.
1. **Read the skill** at `.agents/skills/create-scratch/SKILL.md`
2. **Create the scratch file** using the slug from `$ARGUMENTS` and appropriate content for notes or exploration
-201
View File
@@ -1,201 +0,0 @@
---
description: Generate MDX documentation for a module or feature
argument-hint: <module-path-or-feature>
---
You are creating proper MDX documentation for a module or feature in Documenso using Nextra.
## Your Task
1. **Identify the scope** - What does `$ARGUMENTS` refer to? (file, directory, or feature name)
2. **Read the source code** - Understand the public API, types, and behavior
3. **Read existing docs** - Check if there's documentation to update or reference
4. **Write comprehensive documentation** - Create or update MDX docs in the appropriate location
5. **Update navigation** - Add entry to `_meta.js` if creating a new page
## Documentation Structure
Create documentation in the appropriate location:
- **Developer docs**: `apps/documentation/pages/developers/`
- **User docs**: `apps/documentation/pages/users/`
### File Format
All documentation files must be `.mdx` files with frontmatter:
```mdx
---
title: Page Title
description: Brief description for SEO and meta tags
---
# Page Title
Content starts here...
```
### Navigation
Each directory should have a `_meta.js` file that defines the navigation structure:
```javascript
export default {
index: 'Introduction',
'feature-name': 'Feature Name',
'another-feature': 'Another Feature',
};
```
If creating a new page, add it to the appropriate `_meta.js` file.
### Documentation Format
````mdx
---
title: <Module|Feature Name>
description: Brief description of what this does and when to use it
---
# <Module|Feature Name>
Brief description of what this module/feature does and when to use it.
## Installation
If there are specific packages or imports needed:
```bash
npm install @documenso/package-name
```
## Quick Start
```jsx
// Minimal working example
import { Component } from '@documenso/package';
const Example = () => {
return <Component />;
};
```
## API Reference
### Component/Function Name
Description of what it does.
#### Props/Parameters
| Prop/Param | Type | Description |
| ---------- | -------------------- | ------------------------- |
| prop | `string` | Description of the prop |
| optional | `boolean` (optional) | Optional prop description |
#### Example
```jsx
import { Component } from '@documenso/package';
<Component prop="value" optional={true} />;
```
### Types
#### `TypeName`
```typescript
type TypeName = {
property: string;
optional?: boolean;
};
```
## Examples
### Common Use Case
```jsx
// Full working example
```
### Advanced Usage
```jsx
// More complex example
```
## Related
- [Link to related documentation](/developers/path)
- [Another related page](/users/path)
````
## Guidelines
### Content Quality
- **Be accurate** - Verify behavior by reading the code
- **Be complete** - Document all public API surface
- **Be practical** - Include real, working examples
- **Be concise** - Don't over-explain obvious things
- **Be user-focused** - Write for the target audience (developers or users)
### Code Examples
- Use appropriate language tags: `jsx`, `tsx`, `typescript`, `bash`, `json`
- Show imports when not obvious
- Include expected output in comments where helpful
- Progress from simple to complex
- Use real examples from the codebase when possible
### Formatting
- Always include frontmatter with `title` and `description`
- Use proper markdown headers (h1 for title, h2 for sections)
- Use tables for props/parameters documentation (matching existing style)
- Use code fences with appropriate language tags
- Use Nextra components when appropriate:
- `<Callout type="info">` for notes
- `<Steps>` for step-by-step instructions
- Use relative links for internal documentation (e.g., `/developers/embedding/react`)
### Nextra Components
You can import and use Nextra components:
```jsx
import { Callout, Steps } from 'nextra/components';
<Callout type="info">
This is an informational note.
</Callout>
<Steps>
<Steps.Step>First step</Steps.Step>
<Steps.Step>Second step</Steps.Step>
</Steps>
```
### Maintenance
- Include types inline so docs don't get stale
- Reference source file locations for complex behavior
- Keep examples up-to-date with the codebase
- Update `_meta.js` when adding new pages
## Process
1. **Explore the code** - Read source files to understand the API
2. **Identify the audience** - Is this for developers or users?
3. **Check existing docs** - Look for similar pages to match style
4. **Draft the structure** - Outline sections before writing
5. **Write content** - Fill in each section with frontmatter
6. **Add examples** - Create working code samples
7. **Update navigation** - Add to `_meta.js` if needed
8. **Review** - Read through for clarity and accuracy
## Begin
Analyze `$ARGUMENTS`, read the relevant source code, check existing documentation patterns, and create comprehensive MDX documentation following the Documenso documentation style.
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/agent-browser
@@ -43,7 +43,7 @@ ISO 27001 is an international standard for managing information security, specif
## HIPAA
<Callout type="warn">Status: [Planned](https://github.com/documenso/backlog/issues/25)</Callout>
<Callout type="info">Status: [Compliant](https://documen.so/trust)</Callout>
The HIPAA (Health Insurance Portability and Accountability Act) is a U.S. law designed to protect patient health information's privacy and security and improve the healthcare system's efficiency and effectiveness.
@@ -0,0 +1,56 @@
---
title: Authoring
description: Embed document, template, and envelope creation directly in your application.
---
import { Callout } from 'fumadocs-ui/components/callout';
In addition to embedding signing, Documenso supports embedded authoring. It allows your users to create and edit documents, templates, and envelopes without leaving your application.
<Callout type="warn">
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
also available as a paid add-on for the [Platform Plan](https://documen.so/platform-cta-pricing).
Contact sales for access.
</Callout>
## Versions
Embedded authoring is available in two versions:
- **[V1 Authoring](/docs/developers/embedding/authoring/v1)** — Works with V1 Documents and Templates.
- **[V2 Authoring](/docs/developers/embedding/authoring/v2)** — Works with Envelopes, which are the unified model for documents and templates.
### Comparison
| Aspect | V1 | V2 |
| --- | --- | --- |
| Entity model | Documents and Templates (separate) | Envelopes (unified, can be documents or templates) |
| API compatibility | V1 Documents/Templates API | V2 Envelopes API |
| Customization | 6 simple boolean flags | Rich structured settings with sections (general, settings, actions, envelope items, recipients) |
---
## Presign Tokens
Before using any authoring component, obtain a presign token from your backend:
```
POST /api/v2/embedding/create-presign-token
```
This endpoint requires your Documenso API key. The token has a default expiration of 1 hour.
See the [API documentation](https://openapi.documenso.com/reference#tag/embedding) for full details.
<Callout type="warn">
Presign tokens should be created server-side. Never expose your API key in client-side code.
</Callout>
---
## Next Steps
- [V1 Authoring](/docs/developers/embedding/authoring/v1) — Create and edit documents and templates using V1 components
- [V2 Authoring](/docs/developers/embedding/authoring/v2) — Create and edit envelopes using V2 components
- [CSS Variables](/docs/developers/embedding/css-variables) — Customize the appearance of embedded components
- [SDKs](/docs/developers/embedding/sdks) — Framework-specific SDK documentation
@@ -0,0 +1,4 @@
{
"title": "Authoring",
"pages": ["v1", "v2"]
}
@@ -1,11 +1,11 @@
---
title: Authoring
description: Embed document and template creation directly in your application.
title: V1 Authoring
description: Embed V1 document and template creation directly in your application.
---
import { Callout } from 'fumadocs-ui/components/callout';
In addition to embedding signing, Documenso supports embedded authoring. It allows your users to create and edit documents and templates without leaving your application.
V1 authoring components allow your users to create and edit documents and templates using the V1 Documents and Templates API without leaving your application.
<Callout type="warn">
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
@@ -15,7 +15,7 @@ In addition to embedding signing, Documenso supports embedded authoring. It allo
## Components
The SDK provides four authoring components:
The SDK provides four V1 authoring components:
| Component | Purpose |
| ----------------------- | ----------------------- |
@@ -24,24 +24,16 @@ The SDK provides four authoring components:
| `EmbedUpdateDocumentV1` | Edit existing documents |
| `EmbedUpdateTemplateV1` | Edit existing templates |
All authoring components require a **presign token** for authentication instead of a regular token.
---
## Presign Tokens
Before using any authoring component, obtain a presign token from your backend:
All authoring components require a **presign token** for authentication. See the [Authoring overview](/docs/developers/embedding/authoring) for details on obtaining presign tokens.
```
POST /api/v2/embedding/create-presign-token
```
This endpoint requires your Documenso API key. The token has a default expiration of 1 hour.
See the [API documentation](https://openapi.documenso.com/reference#tag/embedding) for full details.
<Callout type="warn">
Presign tokens should be created server-side. Never expose your API key in client-side code.
A presigned token is NOT an API token
</Callout>
---
@@ -147,7 +139,7 @@ const TemplateEditor = ({ presignToken, templateId }) => {
| `externalId` | `string` | No | Your reference ID to link with the document or template |
| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` |
| `css` | `string` | No | Custom CSS string (Platform Plan) |
| `cssVars` | `object` | No | CSS variable overrides (Platform Plan) |
| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) |
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
| `className` | `string` | No | CSS class for the iframe |
| `features` | `object` | No | Feature toggles for the authoring experience |
@@ -301,6 +293,7 @@ Pass extra props to the iframe for testing experimental features:
## See Also
- [Embedding Overview](/docs/developers/embedding) - Signing embed concepts and props
- [V2 Authoring](/docs/developers/embedding/authoring/v2) - V2 envelope authoring
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
- [Documents API](/docs/developers/api/documents) - Create documents via API
- [Templates API](/docs/developers/api/templates) - Create templates via API
@@ -0,0 +1,341 @@
---
title: V2 Authoring
description: Embed envelope creation and editing directly in your application.
---
import { Callout } from 'fumadocs-ui/components/callout';
V2 authoring components allow your users to create and edit envelopes without leaving your application. Envelopes are the unified model for documents and templates in the V2 API.
<Callout type="warn">
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
also available as a paid add-on for the [Platform Plan](https://documen.so/platform-cta-pricing).
Contact sales for access.
</Callout>
## Components
The SDK provides two V2 authoring components:
| Component | Purpose |
| ---------------------- | ------------------------ |
| `EmbedCreateEnvelope` | Create new envelopes |
| `EmbedUpdateEnvelope` | Edit existing envelopes |
---
## Presign Tokens
All authoring components require a **presign token** for authentication. See the [Authoring overview](/docs/developers/embedding/authoring) for details on obtaining presign tokens.
<Callout type="warn">
A presigned token is NOT an API token
</Callout>
---
## Creating Envelopes
Use `EmbedCreateEnvelope` to embed envelope creation. The `type` prop determines whether the envelope is created as a document or template.
```jsx
import { EmbedCreateEnvelope } from '@documenso/embed-react';
const EnvelopeCreator = ({ presignToken }) => {
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedCreateEnvelope
presignToken={presignToken}
type="DOCUMENT"
externalId="order-12345"
onEnvelopeCreated={(data) => {
console.log('Envelope created:', data.envelopeId);
console.log('External ID:', data.externalId);
}}
/>
</div>
);
};
```
To create a template instead of a document, set `type` to `"TEMPLATE"`:
```jsx
<EmbedCreateEnvelope
presignToken={presignToken}
type="TEMPLATE"
externalId="template-12345"
onEnvelopeCreated={(data) => {
console.log('Template envelope created:', data.envelopeId);
}}
/>
```
---
## Editing Envelopes
Use `EmbedUpdateEnvelope` to embed envelope editing:
```jsx
import { EmbedUpdateEnvelope } from '@documenso/embed-react';
const EnvelopeEditor = ({ presignToken, envelopeId }) => {
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedUpdateEnvelope
presignToken={presignToken}
envelopeId={envelopeId}
externalId="order-12345"
onEnvelopeUpdated={(data) => {
console.log('Envelope updated:', data.envelopeId);
}}
/>
</div>
);
};
```
---
## Props
### All V2 Authoring Components
| Prop | Type | Required | Description |
| ------------------ | --------- | -------- | -------------------------------------------------------- |
| `presignToken` | `string` | Yes | Authentication token from your backend |
| `externalId` | `string` | No | Your reference ID to link with the envelope |
| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` |
| `css` | `string` | No | Custom CSS string (Platform Plan) |
| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) |
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
| `className` | `string` | No | CSS class for the iframe |
| `features` | `object` | No | Feature toggles for the authoring experience |
### Create Component Only
| Prop | Type | Required | Description |
| ---------- | ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `"DOCUMENT"` \| `"TEMPLATE"` | Yes | Whether to create a document or template envelope |
| `folderId` | `string` | No | The ID of the folder to create the envelope in. If not provided, the envelope is created in the root folder. The folder must match the envelope type and team. |
### Update Component Only
| Prop | Type | Required | Description |
| ------------ | -------- | -------- | ---------------------------- |
| `envelopeId` | `string` | Yes | The envelope ID to edit |
---
## Feature Toggles
V2 authoring provides rich, structured feature toggles organized into sections. Pass a partial configuration to customize the authoring experience — any omitted fields will use their defaults.
```jsx
<EmbedCreateEnvelope
presignToken={presignToken}
type="DOCUMENT"
features={{
general: {
allowConfigureEnvelopeTitle: false,
allowUploadAndRecipientStep: false,
allowAddFieldsStep: false,
allowPreviewStep: false,
},
settings: {
allowConfigureSignatureTypes: false,
allowConfigureLanguage: false,
allowConfigureDateFormat: false,
},
recipients: {
allowApproverRole: false,
allowViewerRole: false,
},
}}
/>
```
### General
Controls the overall authoring flow and UI:
| Property | Type | Default | Description |
| ------------------------------- | --------- | ------- | ------------------------------------------------ |
| `allowConfigureEnvelopeTitle` | `boolean` | `true` | Allow editing the envelope title |
| `allowUploadAndRecipientStep` | `boolean` | `true` | Show the upload and recipient configuration step |
| `allowAddFieldsStep` | `boolean` | `true` | Show the add fields step |
| `allowPreviewStep` | `boolean` | `true` | Show the preview step |
| `minimizeLeftSidebar` | `boolean` | `true` | Minimize the left sidebar by default |
### Settings
Controls envelope configuration options. Set to `null` to hide envelope settings entirely.
| Property | Type | Default | Description |
| ----------------------------------- | --------- | ------- | ----------------------------------------- |
| `allowConfigureSignatureTypes` | `boolean` | `true` | Allow configuring signature types |
| `allowConfigureLanguage` | `boolean` | `true` | Allow configuring the language |
| `allowConfigureDateFormat` | `boolean` | `true` | Allow configuring the date format |
| `allowConfigureTimezone` | `boolean` | `true` | Allow configuring the timezone |
| `allowConfigureRedirectUrl` | `boolean` | `true` | Allow configuring a redirect URL |
| `allowConfigureDistribution` | `boolean` | `true` | Allow configuring distribution settings |
| `allowConfigureExpirationPeriod` | `boolean` | `true` | Allow configuring the expiration period |
| `allowConfigureEmailSender` | `boolean` | `true` | Allow configuring the email sender |
| `allowConfigureEmailReplyTo` | `boolean` | `true` | Allow configuring the email reply-to |
### Actions
Controls available actions during authoring:
| Property | Type | Default | Description |
| ------------------ | --------- | ------- | ------------------------ |
| `allowAttachments` | `boolean` | `true` | Allow adding attachments |
### Envelope Items
Controls how envelope items (individual files within the envelope) can be managed. Set to `null` to prevent any item modifications.
| Property | Type | Default | Description |
| --------------------- | --------- | ------- | ------------------------------------ |
| `allowConfigureTitle` | `boolean` | `true` | Allow editing item titles |
| `allowConfigureOrder` | `boolean` | `true` | Allow reordering items |
| `allowUpload` | `boolean` | `true` | Allow uploading new items |
| `allowDelete` | `boolean` | `true` | Allow deleting items |
### Recipients
Controls recipient configuration options. Set to `null` to prevent any recipient modifications.
| Property | Type | Default | Description |
| --------------------------------- | --------- | ------- | ---------------------------------------- |
| `allowConfigureSigningOrder` | `boolean` | `true` | Allow configuring the signing order |
| `allowConfigureDictateNextSigner` | `boolean` | `true` | Allow configuring dictate next signer |
| `allowApproverRole` | `boolean` | `true` | Allow the approver recipient role |
| `allowViewerRole` | `boolean` | `true` | Allow the viewer recipient role |
| `allowCCerRole` | `boolean` | `true` | Allow the CC recipient role |
| `allowAssistantRole` | `boolean` | `true` | Allow the assistant recipient role |
### Disabling Steps
You can also disable entire steps of the authoring flow. This allows you to skip steps that are not relevant to your use case:
```jsx
<EmbedCreateEnvelope
presignToken={presignToken}
type="DOCUMENT"
features={{
general: {
allowUploadAndRecipientStep: false, // Skip the upload and recipient step
allowAddFieldsStep: false, // Skip the add fields step
allowPreviewStep: false, // Skip the preview step
},
settings: null, // Hide all envelope settings
envelopeItems: null, // Prevent item modifications
recipients: null, // Prevent recipient modifications
}}
/>
```
---
## Event Callbacks
### `onEnvelopeCreated`
Fired when an envelope is successfully created:
| Field | Type | Description |
| ------------ | ---------------- | --------------------------------------- |
| `envelopeId` | `string` | The ID of the created envelope |
| `externalId` | `string \| null` | Your external reference ID, if provided |
### `onEnvelopeUpdated`
Fired when an envelope is successfully updated:
| Field | Type | Description |
| ------------ | ---------------- | --------------------------------------- |
| `envelopeId` | `string` | The ID of the updated envelope |
| `externalId` | `string \| null` | Your external reference ID, if provided |
---
## Complete Integration Example
This example shows a full workflow where users create an envelope and then edit it:
```tsx
import { useState } from 'react';
import { EmbedCreateEnvelope, EmbedUpdateEnvelope } from '@documenso/embed-react';
const EnvelopeManager = ({ presignToken }) => {
const [envelopeId, setEnvelopeId] = useState(null);
const [mode, setMode] = useState('create');
if (mode === 'success') {
return (
<div>
<h2>Envelope updated successfully</h2>
<button
onClick={() => {
setEnvelopeId(null);
setMode('create');
}}
>
Create Another Envelope
</button>
</div>
);
}
if (mode === 'edit' && envelopeId) {
return (
<div style={{ height: '800px', width: '100%' }}>
<button onClick={() => setMode('create')}>Back to Create</button>
<EmbedUpdateEnvelope
presignToken={presignToken}
envelopeId={envelopeId}
onEnvelopeUpdated={(data) => {
console.log('Envelope updated:', data.envelopeId);
setMode('success');
}}
/>
</div>
);
}
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedCreateEnvelope
presignToken={presignToken}
type="DOCUMENT"
features={{
general: {
allowConfigureEnvelopeTitle: false,
},
settings: {
allowConfigureSignatureTypes: false,
allowConfigureLanguage: false,
},
}}
onEnvelopeCreated={(data) => {
console.log('Envelope created:', data.envelopeId);
setEnvelopeId(data.envelopeId);
setMode('edit');
}}
/>
</div>
);
};
```
---
## See Also
- [Authoring Overview](/docs/developers/embedding/authoring) - V1 vs V2 comparison and presign tokens
- [V1 Authoring](/docs/developers/embedding/authoring/v1) - V1 document and template authoring
- [Embedding Overview](/docs/developers/embedding) - Signing embed concepts and props
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
@@ -59,6 +59,7 @@ Use the `cssVars` prop on any embed component to override default colors, spacin
| `destructiveForeground` | Destructive/danger text color |
| `ring` | Focus ring color |
| `warning` | Warning/alert color |
| `envelopeEditorBackground` | Envelope editor background color. _V2 Envelope Editor only._ |
### Spacing
@@ -13,7 +13,7 @@ All webhook events share a common structure:
{
"event": "DOCUMENT_COMPLETED",
"payload": {
// Document data with recipients
// Document or template data with recipients
},
"createdAt": "2024-04-22T11:52:18.277Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
@@ -33,14 +33,13 @@ All webhook events share a common structure:
| Field | Type | Description |
| ---------------- | --------- | ------------------------------------------------------ |
| `id` | number | Document ID |
| `id` | number | Document or template ID |
| `externalId` | string? | External identifier for integration |
| `userId` | number | Owner's user ID |
| `authOptions` | object? | Document-level authentication options |
| `formValues` | object? | PDF form values associated with the document |
| `title` | string | Document title |
| `title` | string | Document or template title |
| `status` | string | Current status: `DRAFT`, `PENDING`, `COMPLETED` |
| `documentDataId` | string | Reference to the document's PDF data |
| `visibility` | string | Document visibility setting |
| `createdAt` | datetime | Document creation timestamp |
| `updatedAt` | datetime | Last modification timestamp |
@@ -50,45 +49,50 @@ All webhook events share a common structure:
| `templateId` | number? | Template ID if created from a template |
| `source` | string | Source: `DOCUMENT` or `TEMPLATE` |
| `documentMeta` | object | Document metadata (subject, message, signing options) |
| `Recipient` | array | List of recipient objects |
| `recipients` | array | List of recipient objects |
| `Recipient` | array | List of recipient objects (legacy, same as recipients) |
### Document Metadata Fields
| Field | Type | Description |
| ----------------------- | ------- | --------------------------------------- |
| `id` | string | Metadata record identifier |
| `subject` | string? | Email subject line |
| `message` | string? | Email message body |
| `timezone` | string | Timezone for date display |
| `password` | string? | Document access password (if set) |
| `dateFormat` | string | Date format string |
| `redirectUrl` | string? | URL to redirect after signing |
| `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` |
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
| `language` | string | Document language code |
| `distributionMethod` | string | How document is distributed |
| `emailSettings` | object? | Custom email settings for this document |
| Field | Type | Description |
| -------------------------- | ------- | --------------------------------------- |
| `id` | string | Metadata record identifier |
| `subject` | string? | Email subject line |
| `message` | string? | Email message body |
| `timezone` | string | Timezone for date display |
| `password` | string? | Document access password (if set) |
| `dateFormat` | string | Date format string |
| `redirectUrl` | string? | URL to redirect after signing |
| `signingOrder` | string | `PARALLEL` or `SEQUENTIAL` |
| `allowDictateNextSigner` | boolean | Whether signers can choose the next signer |
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
| `uploadSignatureEnabled` | boolean | Whether uploaded signatures are allowed |
| `drawSignatureEnabled` | boolean | Whether drawn signatures are allowed |
| `language` | string | Document language code |
| `distributionMethod` | string | How document is distributed |
| `emailSettings` | object? | Custom email settings for this document |
### Recipient Fields
| Field | Type | Description |
| ------------------- | --------- | ------------------------------------------ |
| `id` | number | Recipient ID |
| `documentId` | number | Parent document ID |
| `templateId` | number? | Template ID if created from a template |
| `email` | string | Recipient email address |
| `name` | string | Recipient name |
| `token` | string | Unique signing token |
| `documentDeletedAt` | datetime? | When the document was deleted (if deleted) |
| `expired` | boolean? | Whether the recipient's link has expired |
| `signedAt` | datetime? | When recipient signed |
| `authOptions` | object? | Per-recipient authentication options |
| `role` | string | Role: `SIGNER`, `VIEWER`, `APPROVER`, `CC` |
| `signingOrder` | number? | Position in signing sequence |
| `readStatus` | string | `NOT_OPENED` or `OPENED` |
| `signingStatus` | string | `NOT_SIGNED`, `SIGNED`, or `REJECTED` |
| `sendStatus` | string | `NOT_SENT` or `SENT` |
| `rejectionReason` | string? | Reason if recipient rejected |
| Field | Type | Description |
| ---------------------- | --------- | ------------------------------------------ |
| `id` | number | Recipient ID |
| `documentId` | number? | Parent document ID |
| `templateId` | number? | Template ID if created from a template |
| `email` | string | Recipient email address |
| `name` | string | Recipient name |
| `token` | string | Unique signing token |
| `documentDeletedAt` | datetime? | When the recipient hid the document |
| `expiresAt` | datetime? | When the recipient's signing link expires |
| `expirationNotifiedAt` | datetime? | When the expiration notification was sent |
| `signedAt` | datetime? | When recipient signed |
| `authOptions` | object? | Per-recipient authentication options |
| `role` | string | Role: `SIGNER`, `VIEWER`, `APPROVER`, `ASSISTANT`, `CC` |
| `signingOrder` | number? | Position in signing sequence |
| `readStatus` | string | `NOT_OPENED` or `OPENED` |
| `signingStatus` | string | `NOT_SIGNED`, `SIGNED`, or `REJECTED` |
| `sendStatus` | string | `NOT_SENT` or `SENT` |
| `rejectionReason` | string? | Reason if recipient rejected |
---
@@ -98,7 +102,7 @@ These events track the document through its lifecycle.
### `document.created`
Triggered when a new document is uploaded.
Triggered when a new document is created.
**Event name:** `DOCUMENT_CREATED`
@@ -114,7 +118,6 @@ Triggered when a new document is uploaded.
"visibility": "EVERYONE",
"title": "contract.pdf",
"status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:44:43.341Z",
"completedAt": null,
@@ -131,11 +134,35 @@ Triggered when a new document is uploaded.
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
"typedSignatureEnabled": true,
"uploadSignatureEnabled": true,
"drawSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"recipients": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
"name": "John Doe",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expiresAt": null,
"expirationNotifiedAt": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "NOT_SENT"
}
],
"Recipient": [
{
"id": 52,
@@ -145,7 +172,8 @@ Triggered when a new document is uploaded.
"name": "John Doe",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"expiresAt": null,
"expirationNotifiedAt": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
@@ -182,7 +210,6 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"visibility": "EVERYONE",
"title": "contract.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
@@ -199,11 +226,35 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
"typedSignatureEnabled": true,
"uploadSignatureEnabled": true,
"drawSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"recipients": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
"name": "John Doe",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expiresAt": null,
"expirationNotifiedAt": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
],
"Recipient": [
{
"id": 52,
@@ -213,7 +264,8 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"name": "John Doe",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"expiresAt": null,
"expirationNotifiedAt": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
@@ -230,6 +282,106 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
}
```
### `document.opened`
Triggered when a recipient opens the document for the first time.
**Event name:** `DOCUMENT_OPENED`
The recipient's `readStatus` changes to `OPENED`.
```json
{
"event": "DOCUMENT_OPENED",
"payload": {
"id": 10,
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:50:26.174Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
}
```
### `document.signed`
Triggered when a recipient signs the document. This fires for each individual signature, not just when the document is fully completed.
**Event name:** `DOCUMENT_SIGNED`
The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
```json
{
"event": "DOCUMENT_SIGNED",
"payload": {
"id": 10,
"status": "COMPLETED",
"title": "contract.pdf",
"source": "DOCUMENT",
"completedAt": "2024-04-22T11:52:05.707Z",
"recipients": [
{
"id": 51,
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
"signedAt": "2024-04-22T11:52:05.688Z",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:52:18.577Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
}
```
### `document.recipient.completed`
Triggered when an individual recipient completes their required action (signing, approving, or viewing). This is useful for tracking per-recipient progress in documents with multiple recipients.
**Event name:** `DOCUMENT_RECIPIENT_COMPLETED`
```json
{
"event": "DOCUMENT_RECIPIENT_COMPLETED",
"payload": {
"id": 10,
"status": "PENDING",
"title": "contract.pdf",
"source": "DOCUMENT",
"recipients": [
{
"id": 52,
"email": "signer@example.com",
"name": "John Doe",
"role": "SIGNER",
"signedAt": "2024-04-22T11:52:05.688Z",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:52:06.000Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
}
```
### `document.completed`
Triggered when all recipients have completed their required actions.
@@ -250,7 +402,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"visibility": "EVERYONE",
"title": "contract.pdf",
"status": "COMPLETED",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:52:05.708Z",
"completedAt": "2024-04-22T11:52:05.707Z",
@@ -267,12 +418,15 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
"typedSignatureEnabled": true,
"uploadSignatureEnabled": true,
"drawSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
"recipients": [
{
"id": 50,
"documentId": 10,
@@ -281,7 +435,8 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"name": "Jane Smith",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"expiresAt": null,
"expirationNotifiedAt": null,
"signedAt": "2024-04-22T11:51:10.055Z",
"authOptions": {
"accessAuth": null,
@@ -302,7 +457,54 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"name": "John Doe",
"token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expired": null,
"expiresAt": null,
"expirationNotifiedAt": null,
"signedAt": "2024-04-22T11:52:05.688Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"signingOrder": 2,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
}
],
"Recipient": [
{
"id": 50,
"documentId": 10,
"templateId": null,
"email": "reviewer@example.com",
"name": "Jane Smith",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expiresAt": null,
"expirationNotifiedAt": null,
"signedAt": "2024-04-22T11:51:10.055Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": null,
"role": "VIEWER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
},
{
"id": 51,
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
"name": "John Doe",
"token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expiresAt": null,
"expirationNotifiedAt": null,
"signedAt": "2024-04-22T11:52:05.688Z",
"authOptions": {
"accessAuth": null,
@@ -335,53 +537,17 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
"event": "DOCUMENT_REJECTED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "contract.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"title": "contract.pdf",
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
"recipients": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
"name": "John Doe",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": "2024-04-22T11:48:07.569Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": "I do not agree with the terms",
"role": "SIGNER",
"signedAt": "2024-04-22T11:48:07.569Z",
"rejectionReason": "I do not agree with the terms",
"readStatus": "OPENED",
"signingStatus": "REJECTED",
"sendStatus": "SENT"
@@ -395,7 +561,9 @@ The recipient's `signingStatus` changes to `REJECTED` and `rejectionReason` cont
### `document.cancelled`
Triggered when the document owner cancels a pending document.
Triggered when the document owner or a team member deletes a document. Draft and pending documents are hard-deleted, while completed documents are soft-deleted.
This event is **not** triggered when a recipient hides a document from their inbox.
**Event name:** `DOCUMENT_CANCELLED`
@@ -411,7 +579,6 @@ Triggered when the document owner cancels a pending document.
"visibility": "EVERYONE",
"title": "contract.pdf",
"status": "PENDING",
"documentDataId": "cm6exvn93006hi02ru90a265a",
"createdAt": "2025-01-27T11:02:14.393Z",
"updatedAt": "2025-01-27T11:03:16.387Z",
"completedAt": null,
@@ -428,11 +595,35 @@ Triggered when the document owner cancels a pending document.
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": "",
"signingOrder": "PARALLEL",
"allowDictateNextSigner": false,
"typedSignatureEnabled": true,
"uploadSignatureEnabled": true,
"drawSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"recipients": [
{
"id": 7,
"documentId": 7,
"templateId": null,
"email": "signer@example.com",
"name": "John Doe",
"token": "XkKx1HCs6Znm2UBJA2j6o",
"documentDeletedAt": null,
"expiresAt": null,
"expirationNotifiedAt": null,
"signedAt": null,
"authOptions": { "accessAuth": null, "actionAuth": null },
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
],
"Recipient": [
{
"id": 7,
@@ -442,7 +633,8 @@ Triggered when the document owner cancels a pending document.
"name": "John Doe",
"token": "XkKx1HCs6Znm2UBJA2j6o",
"documentDeletedAt": null,
"expired": null,
"expiresAt": null,
"expirationNotifiedAt": null,
"signedAt": null,
"authOptions": { "accessAuth": null, "actionAuth": null },
"signingOrder": 1,
@@ -459,147 +651,127 @@ Triggered when the document owner cancels a pending document.
}
```
---
### `document.reminder.sent`
## Recipient Events
Triggered when a reminder email is sent to a recipient who has not yet completed their action.
Recipient events track individual signer actions. These events use the same payload structure as document events, but focus on a specific recipient's action.
### `document.opened`
Triggered when a recipient opens the document for the first time.
**Event name:** `DOCUMENT_OPENED`
The recipient's `readStatus` changes to `OPENED`.
**Event name:** `DOCUMENT_REMINDER_SENT`
```json
{
"event": "DOCUMENT_OPENED",
"event": "DOCUMENT_REMINDER_SENT",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "contract.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"templateId": null,
"title": "contract.pdf",
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
"recipients": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
"name": "John Doe",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"documentDeletedAt": null,
"expired": null,
"signedAt": null,
"authOptions": null,
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:50:26.174Z",
"createdAt": "2024-04-23T09:00:00.000Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
}
```
### `document.signed`
---
Triggered when a recipient signs the document.
## Template Events
**Event name:** `DOCUMENT_SIGNED`
Template events track changes to reusable document templates. Template payloads use the same structure as document payloads, with `source` set to `TEMPLATE` and `templateId` populated.
The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
### `template.created`
Triggered when a new template is created.
**Event name:** `TEMPLATE_CREATED`
```json
{
"event": "DOCUMENT_SIGNED",
"event": "TEMPLATE_CREATED",
"payload": {
"id": 10,
"externalId": null,
"userId": 1,
"authOptions": null,
"formValues": null,
"visibility": "EVERYONE",
"title": "contract.pdf",
"status": "COMPLETED",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:52:05.708Z",
"completedAt": "2024-04-22T11:52:05.707Z",
"deletedAt": null,
"teamId": null,
"templateId": null,
"source": "DOCUMENT",
"documentMeta": {
"id": "doc_meta_123",
"subject": "Please sign this document",
"message": "Hello, please review and sign this document.",
"timezone": "UTC",
"password": null,
"dateFormat": "MM/DD/YYYY",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"typedSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
},
"Recipient": [
{
"id": 51,
"documentId": 10,
"templateId": null,
"email": "signer@example.com",
"name": "John Doe",
"token": "HkrptwS42ZBXdRKj1TyUo",
"documentDeletedAt": null,
"expired": null,
"signedAt": "2024-04-22T11:52:05.688Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"signingOrder": 1,
"rejectionReason": null,
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
}
]
"title": "My Template",
"status": "DRAFT",
"templateId": 10,
"source": "TEMPLATE",
"recipients": []
},
"createdAt": "2024-04-22T11:52:18.577Z",
"createdAt": "2024-04-22T11:44:44.779Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
}
```
### `template.updated`
Triggered when a template's settings, recipients, or fields are modified.
**Event name:** `TEMPLATE_UPDATED`
```json
{
"event": "TEMPLATE_UPDATED",
"payload": {
"id": 10,
"title": "My Updated Template",
"status": "DRAFT",
"templateId": 10,
"source": "TEMPLATE",
"recipients": []
},
"createdAt": "2024-04-22T12:00:00.000Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
}
```
### `template.deleted`
Triggered when a template is deleted.
**Event name:** `TEMPLATE_DELETED`
```json
{
"event": "TEMPLATE_DELETED",
"payload": {
"id": 10,
"title": "Deleted Template",
"status": "DRAFT",
"templateId": 10,
"source": "TEMPLATE",
"recipients": []
},
"createdAt": "2024-04-22T13:00:00.000Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
}
```
### `template.used`
Triggered when a document is created from a template. This event fires alongside `document.created`, giving you a way to specifically track template usage.
**Event name:** `TEMPLATE_USED`
```json
{
"event": "TEMPLATE_USED",
"payload": {
"id": 10,
"title": "Document from Template",
"status": "DRAFT",
"templateId": 10,
"source": "TEMPLATE",
"recipients": []
},
"createdAt": "2024-04-22T14:00:00.000Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
}
```
@@ -608,15 +780,28 @@ The recipient's `signingStatus` changes to `SIGNED` and `signedAt` is populated.
## Event Summary
| Event | Trigger | Key Changes |
| -------------------- | ------------------------------- | ------------------------------------------------------------ |
| `DOCUMENT_CREATED` | Document uploaded | `status: "DRAFT"` |
| `DOCUMENT_SENT` | Document sent to recipients | `status: "PENDING"`, recipients `sendStatus: "SENT"` |
| `DOCUMENT_OPENED` | Recipient opens document | Recipient `readStatus: "OPENED"` |
| `DOCUMENT_SIGNED` | Recipient signs document | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
| `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set |
| `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set |
| `DOCUMENT_CANCELLED` | Owner cancels document | Document cancelled while pending |
### Document Events
| Event | Trigger | Key Changes |
| ---------------------------- | ------------------------------------------- | ------------------------------------------------------------ |
| `DOCUMENT_CREATED` | Document uploaded or created from template | `status: "DRAFT"` |
| `DOCUMENT_SENT` | Document sent to recipients | `status: "PENDING"`, recipients `sendStatus: "SENT"` |
| `DOCUMENT_OPENED` | Recipient opens document for the first time | Recipient `readStatus: "OPENED"` |
| `DOCUMENT_SIGNED` | Recipient signs document | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
| `DOCUMENT_RECIPIENT_COMPLETED` | Recipient completes their action | Recipient `signingStatus: "SIGNED"`, `signedAt` set |
| `DOCUMENT_COMPLETED` | All recipients complete actions | `status: "COMPLETED"`, `completedAt` set |
| `DOCUMENT_REJECTED` | Recipient rejects document | Recipient `signingStatus: "REJECTED"`, `rejectionReason` set |
| `DOCUMENT_CANCELLED` | Owner or team member deletes document | Document cancelled or deleted |
| `DOCUMENT_REMINDER_SENT` | Reminder email sent to recipient | No status changes |
### Template Events
| Event | Trigger | Key Changes |
| ------------------ | ------------------------------------ | ------------------------- |
| `TEMPLATE_CREATED` | New template created | `source: "TEMPLATE"` |
| `TEMPLATE_UPDATED` | Template settings or fields modified | `source: "TEMPLATE"` |
| `TEMPLATE_DELETED` | Template deleted | `source: "TEMPLATE"` |
| `TEMPLATE_USED` | Document created from template | `source: "TEMPLATE"` |
---
@@ -652,19 +837,22 @@ app.post('/webhook', (req, res) => {
switch (event) {
case 'DOCUMENT_COMPLETED':
// Handle completed document
console.log(`Document ${payload.id} completed`);
break;
case 'DOCUMENT_RECIPIENT_COMPLETED':
const signer = payload.recipients.find((r) => r.signingStatus === 'SIGNED');
console.log(`${signer?.name} completed their action on document ${payload.id}`);
break;
case 'DOCUMENT_SIGNED':
// Handle signature
const signer = payload.Recipient.find((r) => r.signingStatus === 'SIGNED');
console.log(`${signer?.name} signed document ${payload.id}`);
console.log(`Signature added to document ${payload.id}`);
break;
case 'DOCUMENT_REJECTED':
// Handle rejection
const rejecter = payload.Recipient.find((r) => r.signingStatus === 'REJECTED');
const rejecter = payload.recipients.find((r) => r.signingStatus === 'REJECTED');
console.log(`${rejecter?.name} rejected: ${rejecter?.rejectionReason}`);
break;
case 'TEMPLATE_USED':
console.log(`Template ${payload.templateId} used to create document ${payload.id}`);
break;
}
res.status(200).send('OK');
@@ -1,6 +1,6 @@
---
title: Webhooks
description: Receive real-time notifications when documents are signed, completed, or updated.
description: Receive real-time notifications for document and template events.
---
## How Webhooks Work
@@ -9,6 +9,8 @@ description: Receive real-time notifications when documents are signed, complete
2. When an event occurs, Documenso sends an HTTP POST to your URL
3. Your application processes the event and responds with 200 OK
Documenso supports webhook events for the full document lifecycle (created, sent, opened, signed, completed, rejected, cancelled) as well as template events (created, updated, deleted, used).
---
## Getting Started
@@ -41,7 +43,15 @@ description: Receive real-time notifications when documents are signed, complete
"payload": {
"id": 123,
"title": "Contract",
"status": "COMPLETED"
"status": "COMPLETED",
"completedAt": "2024-01-15T10:30:00.000Z",
"recipients": [
{
"id": 1,
"email": "signer@example.com",
"signingStatus": "SIGNED"
}
]
},
"createdAt": "2024-01-15T10:30:00.000Z",
"webhookEndpoint": "https://your-endpoint.com/webhook"
@@ -220,11 +220,17 @@ When creating a webhook, you can subscribe to one or more events:
| ----- | ------- |
| `DOCUMENT_CREATED` | A new document is created |
| `DOCUMENT_SENT` | A document is sent to recipients |
| `DOCUMENT_OPENED` | A recipient opens the document |
| `DOCUMENT_OPENED` | A recipient opens the document for the first time |
| `DOCUMENT_SIGNED` | A recipient signs the document |
| `DOCUMENT_COMPLETED` | All recipients have signed the document |
| `DOCUMENT_RECIPIENT_COMPLETED` | A recipient completes their required action |
| `DOCUMENT_COMPLETED` | All recipients have completed their actions |
| `DOCUMENT_REJECTED` | A recipient rejects the document |
| `DOCUMENT_CANCELLED` | The document owner cancels the document |
| `DOCUMENT_CANCELLED` | The document owner deletes the document |
| `DOCUMENT_REMINDER_SENT` | A reminder email is sent to a recipient |
| `TEMPLATE_CREATED` | A new template is created |
| `TEMPLATE_UPDATED` | A template is modified |
| `TEMPLATE_DELETED` | A template is deleted |
| `TEMPLATE_USED` | A document is created from a template |
You can subscribe to all events or select specific ones based on your needs. For example, if you only need to know when documents are fully signed, subscribe only to `DOCUMENT_COMPLETED`.
@@ -250,9 +250,15 @@ const validEvents = [
'DOCUMENT_SENT',
'DOCUMENT_OPENED',
'DOCUMENT_SIGNED',
'DOCUMENT_RECIPIENT_COMPLETED',
'DOCUMENT_COMPLETED',
'DOCUMENT_REJECTED',
'DOCUMENT_CANCELLED',
'DOCUMENT_REMINDER_SENT',
'TEMPLATE_CREATED',
'TEMPLATE_UPDATED',
'TEMPLATE_DELETED',
'TEMPLATE_USED',
];
if (!validEvents.includes(event)) {
@@ -224,11 +224,31 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
## Feature Flags
| Variable | Description | Default |
| ------------------------------------- | ----------------------------------------------- | ------- |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration | `false` |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
| Variable | Description | Default |
| ------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration entirely | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
### Signup Restrictions
You can control who is allowed to create accounts on your instance using two environment variables:
- **`NEXT_PUBLIC_DISABLE_SIGNUP`**: Set to `true` to block all new signups. Existing users can still sign in. This applies to both email/password and OAuth signups.
- **`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`**: Restrict signups to specific email domains. When set, only users whose email address matches one of the listed domains can create an account. Leave empty to allow all domains.
Both restrictions apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
When both variables are set, `NEXT_PUBLIC_DISABLE_SIGNUP` takes precedence. Signups are blocked regardless of the domain list.
```bash
# Allow signups only from specific domains
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
# Or disable signups entirely
NEXT_PUBLIC_DISABLE_SIGNUP="true"
```
---
@@ -271,6 +291,8 @@ Documenso uses a PostgreSQL-based job queue by default. Jobs (email delivery, do
| ----------------------------- | -------------------------------------------- | ------- |
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry | `false` |
Telemetry also auto-disables when `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` is configured.
Telemetry collects only: app version, installation ID, and node ID. No personal data is collected.
---
@@ -326,6 +348,10 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@example.com"
# Signing (certificate must be configured)
NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
# Signup restrictions (optional)
# NEXT_PUBLIC_DISABLE_SIGNUP="true"
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
```
---
@@ -154,8 +154,9 @@ PORT=3000
# Signing certificate (see Signing Certificate section)
NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password
# Disable public signups
# Signup restrictions (optional)
NEXT_PUBLIC_DISABLE_SIGNUP=false
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
```
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
@@ -251,7 +252,8 @@ Navigate to the signup page and create your account. Verify your email address
<Callout type="info">
All accounts created through signup are regular user accounts. Admin access must be granted
directly in the database. Once your accounts are set up, consider disabling public signups by
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`.
setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`, or restrict signups to specific email domains with
`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`.
</Callout>
## Managing Services
@@ -101,6 +101,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
@@ -153,8 +153,9 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
| Variable | Description | Default |
| --------------------------------- | ---------------------------------- | ------- |
| `PORT` | Application port | `3000` |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
</Step>
@@ -22,6 +22,37 @@ Organisation members have different permission levels that determine what they c
organisation.
</Callout>
## Transferring Organisation Ownership
Organisation ownership cannot be transferred through the regular organisation settings. Only a Documenso instance administrator can transfer ownership through the admin panel.
If you are using Documenso Cloud, contact support to request an ownership transfer. If you are self-hosting, an instance administrator can follow the steps below.
The target user must already be a member of the organisation.
{/* prettier-ignore */}
<Steps>
<Step>
Navigate to **Admin > Organisations** and select the organisation.
</Step>
<Step>
In the **Organisation Members** table, find the target member and click **Update role**.
</Step>
<Step>
Select **Owner** from the role dropdown and click **Update**.
</Step>
</Steps>
After the transfer:
- The new owner is promoted to Admin if they previously held a lower role (Manager or Member).
- The previous owner retains their Admin role and remains a member of the organisation.
- Only one user can be the owner at a time.
<Callout type="warn">
The current owner cannot be demoted below Admin. Transfer ownership to another member first.
</Callout>
## Team Member Roles
Teams have three roles with different permission levels:
@@ -135,7 +135,9 @@ Additional options that apply to all documents created from this template:
## Template Visibility
All templates are created in a team context. Team members can see, edit, delete, and use the templates in that team. See [Organisations](/docs/users/organisations) to learn about creating and managing organisations.
All templates are created in a team context. By default, templates are **Private** and only visible to members of the owning team.
If your organisation has multiple teams, you can set a template's type to **Organisation** to share it across all teams. See [Organisation Templates](/docs/users/templates/organisation-templates) for details.
---
@@ -24,6 +24,11 @@ description: Create reusable document templates for common signing workflows.
description="Create documents from your templates."
href="/docs/users/templates/use"
/>
<Card
title="Organisation Templates"
description="Share templates across all teams in your organisation."
href="/docs/users/templates/organisation-templates"
/>
</Cards>
---
@@ -1,4 +1,4 @@
{
"title": "Templates",
"pages": ["create", "use"]
"pages": ["create", "use", "organisation-templates"]
}
@@ -0,0 +1,131 @@
---
title: Organisation Templates
description: Share templates across all teams in your organisation so any team can create documents from them.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
## Overview
Organisation templates are templates shared across all teams within the same organisation. Any team in the organisation can browse and use them to create documents, but only the owning team can edit or delete them.
This is useful when you have standardised documents that multiple teams need to use, such as company-wide NDAs, onboarding agreements, or compliance forms.
## Requirements
The Organisation template type is available when your organisation has **two or more teams**. If your organisation has only one team, the option does not appear.
## Template Types
| Type | Who can see it | Who can edit it | Who can use it |
| ---------------- | ------------------------- | ----------------- | --------------------------- |
| **Private** | Members of the owning team | Owning team | Owning team |
| **Organisation** | All teams in the org | Owning team only | All teams in the org |
| **Public** | Anyone with the link | Owning team | Anyone via direct link |
## Set a Template as Organisation
{/* prettier-ignore */}
<Steps>
<Step>
### Open template settings
Navigate to **Templates**, open the template you want to share, and click **Edit Template** to open the editor. Then open the settings dialog.
</Step>
<Step>
### Change the template type
In the **Template type** dropdown, select **Organisation**. This option only appears if your organisation has at least two teams.
</Step>
<Step>
### Save
Click **Save** to apply the change. The template is now visible to all teams in your organisation.
</Step>
</Steps>
You can also set the template type to Organisation when creating a new template. The type dropdown appears in the template settings step.
## Browse Organisation Templates
{/* prettier-ignore */}
<Steps>
<Step>
### Open the templates page
Navigate to **Templates** in the sidebar.
</Step>
<Step>
### Switch to the Organisation tab
Click the **Organisation** tab above the template list. This tab only appears for non-personal organisations.
The Organisation tab shows all organisation templates from every team in your organisation, including your own.
</Step>
</Steps>
Templates from other teams display the owning team's name next to the template type.
## Use an Organisation Template
Any team member in the organisation can create documents from an organisation template, even if the template belongs to a different team.
{/* prettier-ignore */}
<Steps>
<Step>
Find the template in the **Organisation** tab or click through from the template detail page.
</Step>
<Step>
Click **Use Template** and fill in the recipient details. The document is created under your team, not the template's owning team.
</Step>
</Steps>
See [Use Templates](/docs/users/templates/use) for details on creating documents from templates.
## Editing and Permissions
Only members of the team that owns the template can edit or delete it. When viewing an organisation template from another team:
- The **Edit Template**, **Direct Link**, and **Bulk Send** controls are hidden
- The recipients section is read-only
- The **Use Template** button is available
To modify a template owned by another team, contact that team's members or ask an organisation admin to make changes.
## Visibility
Organisation templates respect the same visibility settings as other templates. A template's visibility determines which team roles can access it:
| Visibility | Who can access |
| --------------------- | --------------------------------------- |
| **Everyone** | All team members (Admin, Manager, Member) |
| **Manager and above** | Admins and Managers only |
| **Admin** | Admins only |
This applies to both the owning team and other teams in the organisation. A Member-role user on any team cannot see an organisation template set to Admin visibility.
## Reverting to Private
To stop sharing a template across the organisation, change the template type back to **Private** in the template settings. The template will only be visible to the owning team. Documents already created from the template are not affected.
---
## See Also
- [Create Templates](/docs/users/templates/create) - Build reusable templates
- [Use Templates](/docs/users/templates/use) - Create documents from templates
- [Organisations](/docs/users/organisations) - Managing organisations and teams
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -12,6 +12,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -16,6 +16,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
status: 200,
headers: {
'content-type': 'application/json',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200',
},
}),
);
+27 -28
View File
@@ -1,15 +1,20 @@
import { DateTime } from 'luxon';
export interface TransformedData {
export type TransformedData = {
labels: string[];
datasets: Array<{
label: string;
data: number[];
}>;
}
};
export function addZeroMonth(transformedData: TransformedData): TransformedData {
const result = {
const FORMAT = 'MMM yyyy';
export const addZeroMonth = (
transformedData: TransformedData,
isCumulative = false,
): TransformedData => {
const result: TransformedData = {
labels: [...transformedData.labels],
datasets: transformedData.datasets.map((dataset) => ({
label: dataset.label,
@@ -21,34 +26,28 @@ export function addZeroMonth(transformedData: TransformedData): TransformedData
return result;
}
if (result.datasets.every((dataset) => dataset.data[0] === 0)) {
return result;
}
try {
let firstMonth = DateTime.fromFormat(result.labels[0], 'MMM yyyy');
if (!result.datasets.every((dataset) => dataset.data[0] === 0)) {
const firstMonth = DateTime.fromFormat(result.labels[0], FORMAT);
if (!firstMonth.isValid) {
const formats = ['MMM yyyy', 'MMMM yyyy', 'MM/yyyy', 'yyyy-MM'];
for (const format of formats) {
firstMonth = DateTime.fromFormat(result.labels[0], format);
if (firstMonth.isValid) break;
}
if (!firstMonth.isValid) {
console.warn(`Could not parse date: "${result.labels[0]}"`);
return transformedData;
}
console.warn(`Could not parse date: "${result.labels[0]}"`);
return transformedData;
}
const zeroMonth = firstMonth.minus({ months: 1 }).toFormat('MMM yyyy');
result.labels.unshift(zeroMonth);
result.labels.unshift(firstMonth.minus({ months: 1 }).toFormat(FORMAT));
result.datasets.forEach((dataset) => {
dataset.data.unshift(0);
});
return result;
} catch (error) {
return transformedData;
}
}
const now = DateTime.now().startOf('month');
const lastMonth = DateTime.fromFormat(result.labels[result.labels.length - 1], FORMAT);
if (lastMonth.isValid && lastMonth.startOf('month') < now) {
result.labels.push(now.toFormat(FORMAT));
result.datasets.forEach((dataset) => {
dataset.data.push(isCumulative ? dataset.data[dataset.data.length - 1] : 0);
});
}
return result;
};
@@ -21,8 +21,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
.groupBy('month')
.orderBy('month', 'desc')
.limit(12);
.orderBy('month', 'desc');
const result = await qb.execute();
@@ -38,7 +37,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
],
};
return addZeroMonth(transformedData);
return addZeroMonth(transformedData, type === 'cumulative');
};
export type GetCompletedDocumentsMonthlyResult = Awaited<
@@ -36,7 +36,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
],
};
return addZeroMonth(transformedData);
return addZeroMonth(transformedData, type === 'cumulative');
};
export type GetSignerConversionMonthlyResult = Awaited<
@@ -17,8 +17,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
.as('cume_count'),
])
.groupBy('month')
.orderBy('month', 'desc')
.limit(12);
.orderBy('month', 'desc');
const result = await qb.execute();
@@ -34,7 +33,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
],
};
return addZeroMonth(transformedData);
return addZeroMonth(transformedData, type === 'cumulative');
};
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
+3 -14
View File
@@ -1,6 +1,6 @@
import { DateTime } from 'luxon';
import { addZeroMonth } from './add-zero-month';
import { type TransformedData, addZeroMonth } from './add-zero-month';
type MetricKeys = {
stars: number;
@@ -14,14 +14,6 @@ type DataEntry = {
[key: string]: MetricKeys;
};
type TransformData = {
labels: string[];
datasets: {
label: string;
data: number[];
}[];
};
type MetricKey = keyof MetricKeys;
const FRIENDLY_METRIC_NAMES: { [key in MetricKey]: string } = {
@@ -38,7 +30,7 @@ export function transformData({
}: {
data: DataEntry;
metric: MetricKey;
}): TransformData {
}): TransformedData {
try {
if (!data || Object.keys(data).length === 0) {
return {
@@ -103,7 +95,7 @@ export function transformData({
],
};
return addZeroMonth(transformedData);
return addZeroMonth(transformedData, true);
} catch (error) {
return {
labels: [],
@@ -111,6 +103,3 @@ export function transformData({
};
}
}
// To be on the safer side
export const transformRepoStats = transformData;
@@ -57,7 +57,7 @@ export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Create Subscription Claim</Trans>
@@ -53,7 +53,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Update Subscription Claim</Trans>
@@ -13,7 +13,6 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
@@ -38,19 +37,6 @@ export const DocumentDuplicateDialog = ({
const team = useCurrentTeam();
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpcReact.envelope.item.getManyByToken.useQuery(
{
envelopeId: id,
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
enabled: open,
},
);
const envelopeItems = envelopeItemsPayload?.data || [];
const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
@@ -88,22 +74,6 @@ export const DocumentDuplicateDialog = ({
<Trans>Duplicate</Trans>
</DialogTitle>
</DialogHeader>
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
<Trans>Loading Document...</Trans>
</h1>
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={undefined}
version="original"
/>
</div>
)}
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type EnvelopeDeleteDialogProps = {
id: string;
type: EnvelopeType;
trigger?: React.ReactNode;
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
title: string;
canManageDocument: boolean;
};
export const EnvelopeDeleteDialog = ({
id,
type,
trigger,
onDelete,
status,
title,
canManageDocument,
}: EnvelopeDeleteDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { t } = useLingui();
const deleteMessage = msg`delete`;
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: t`Document deleted`,
description: t`"${title}" has been successfully deleted`,
duration: 5000,
});
await onDelete?.();
setOpen(false);
},
onError: () => {
toast({
title: t`Something went wrong`,
description: t`This document could not be deleted at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === t(deleteMessage));
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to delete <strong>"{title}"</strong>
</Trans>
) : (
<Trans>
You are about to hide <strong>"{title}"</strong>
</Trans>
)}
</DialogDescription>
</DialogHeader>
{canManageDocument ? (
<Alert variant="warning" className="-mt-1">
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
{type === EnvelopeType.DOCUMENT ? (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
) : (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this template will be permanently deleted.
</Trans>
)}
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
<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">
<li>
<Trans>Document will be permanently deleted</Trans>
</li>
<li>
<Trans>Document signing process will be cancelled</Trans>
</li>
<li>
<Trans>All inserted signatures will be voided</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</ul>
</AlertDescription>
))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
<AlertDescription>
<p>
<Trans>By deleting this document, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>The document will be hidden from your account</Trans>
</li>
<li>
<Trans>Recipients will still retain their copy of the document</Trans>
</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
<Trans>Please contact support if you would like to revert this action.</Trans>
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder={t`Please type ${`'${t(deleteMessage)}'`} to confirm`}
/>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void deleteEnvelope({ envelopeId: id })}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? <Trans>Delete</Trans> : <Trans>Hide</Trans>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -13,6 +13,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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
@@ -116,10 +117,15 @@ export const EnvelopeDistributeDialog = ({
} = form;
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
trpc.enterprise.organisation.email.find.useQuery(
{
organisationId: organisation.id,
perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const emails = emailData?.data || [];
@@ -5,18 +5,18 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base';
import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer';
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
@@ -24,14 +24,15 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
import { Form } from '@documenso/ui/primitives/form/form';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { FieldAdvancedSettingsDrawer } from '~/components/embed/authoring/field-advanced-settings-drawer';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
@@ -42,7 +43,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema;
presignToken?: string | undefined;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>;
defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack?: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void;
@@ -86,23 +87,22 @@ export const ConfigureFieldsView = ({
const normalizedDocumentData = useMemo(() => {
if (envelopeItem) {
return undefined;
return getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItem.envelopeId,
envelopeItemId: envelopeItem.id,
documentDataId: envelopeItem.documentDataId,
version: 'current',
token: undefined,
presignToken,
});
}
if (!configData.documentData) {
return undefined;
}
return base64.encode(configData.documentData.data);
}, [configData.documentData]);
const normalizedEnvelopeItem = useMemo(() => {
if (envelopeItem) {
return envelopeItem;
}
return { id: '', envelopeId: '' };
}, [envelopeItem]);
return configData.documentData.data;
}, [configData.documentData, envelopeItem, presignToken]);
const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({
@@ -155,9 +155,7 @@ export const ConfigureFieldsView = ({
});
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
const selectedRecipientStyles = useRecipientColors(
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
);
const selectedRecipientStyles = getRecipientColorStyles(selectedRecipientIndex);
const form = useForm<TConfigureFieldsFormSchema>({
defaultValues: {
@@ -179,8 +177,6 @@ export const ConfigureFieldsView = ({
name: 'fields',
});
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
@@ -205,13 +201,15 @@ export const ConfigureFieldsView = ({
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
const totalPages = getPdfPagesCount();
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (totalPages < 1) {
return;
}
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === lastActiveField.pageNumber) {
return;
continue;
}
const newField: TConfigureFieldsFormSchema['fields'][0] = {
@@ -224,7 +222,7 @@ export const ConfigureFieldsView = ({
};
append(newField);
});
}
return;
}
@@ -548,17 +546,11 @@ export const ConfigureFieldsView = ({
<Form {...form}>
<div>
<PDFViewerLazy
presignToken={presignToken}
overrideData={normalizedDocumentData}
envelopeItem={normalizedEnvelopeItem}
token={undefined}
version="signed"
/>
{normalizedDocumentData && (
<PDFViewerLazy data={normalizedDocumentData} scrollParentRef="window" />
)}
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
@@ -19,11 +19,13 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
@@ -35,12 +37,11 @@ import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo';
import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
@@ -54,7 +55,7 @@ export type EmbedDirectTemplateClientPageProps = {
token: string;
envelopeId: string;
updatedAt: Date;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>[];
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | null;
@@ -97,12 +98,10 @@ export const EmbedDirectTemplateClientPage = ({
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
const [pendingFields, _completedFields] = [
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
sortFieldsByPosition(localFields.filter((field) => isFieldUnsignedAndRequired(field))),
localFields.filter((field) => field.inserted),
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
@@ -342,9 +341,15 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */}
<div className="flex-1">
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={recipient.token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentDataId,
version: 'current',
token: recipient.token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
@@ -478,15 +483,15 @@ export const EmbedDirectTemplateClientPage = ({
</div>
</div>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
{showPendingFieldTooltip && pendingFields.length > 0 && (
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
>
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
</ElementVisible>
)}
{/* Fields */}
<EmbedDocumentFields
@@ -50,10 +50,8 @@ export const EmbedDocumentFields = ({
onSignField,
onUnsignField,
}: EmbedDocumentFieldsProps) => {
const highestPageNumber = Math.max(...fields.map((field) => field.page));
return (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
@@ -9,8 +9,10 @@ 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 { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition, 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';
@@ -23,15 +25,14 @@ import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
@@ -45,7 +46,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
token: string;
documentId: number;
envelopeId: string;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
envelopeItems: (Pick<EnvelopeItem, 'id' | 'envelopeId'> & { documentData: { id: string } })[];
recipient: RecipientWithFields;
fields: Field[];
completedFields: DocumentField[];
@@ -73,7 +74,7 @@ export const EmbedSignDocumentV1ClientPage = ({
const { _ } = useLingui();
const { toast } = useToast();
const { fullName, email, signature, setFullName, setSignature } =
const { fullName, email, signature, setFullName, setEmail, setSignature } =
useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
@@ -88,6 +89,7 @@ export const EmbedSignDocumentV1ClientPage = ({
const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(!!email);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
useState(false);
@@ -100,14 +102,14 @@ export const EmbedSignDocumentV1ClientPage = ({
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [pendingFields, _completedFields] = [
fields.filter(
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
sortFieldsByPosition(
fields.filter(
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
),
),
fields.filter((field) => field.inserted),
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
trpc.recipient.completeDocumentWithToken.useMutation();
@@ -204,13 +206,19 @@ export const EmbedSignDocumentV1ClientPage = ({
try {
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (!isCompleted && data.name) {
if (!isCompleted && data.name && !fullName) {
setFullName(data.name);
}
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
if (!isCompleted && data.email && !email) {
setEmail(data.email);
setIsEmailLocked(!!data.lockEmail);
}
setAllowDocumentRejection(!!data.allowDocumentRejection);
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
@@ -288,9 +296,15 @@ export const EmbedSignDocumentV1ClientPage = ({
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentData.id,
version: 'current',
token: token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
@@ -442,7 +456,8 @@ export const EmbedSignDocumentV1ClientPage = ({
id="email"
className="mt-2 bg-background"
value={email}
disabled
onChange={(e) => setEmail(e.target.value)}
disabled={isEmailLocked}
/>
</div>
@@ -491,15 +506,15 @@ export const EmbedSignDocumentV1ClientPage = ({
</div>
</div>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
{showPendingFieldTooltip && pendingFields.length > 0 && (
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
>
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
</ElementVisible>
)}
{/* Fields */}
<EmbedDocumentFields fields={fields} metadata={metadata} />
@@ -3,9 +3,9 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { useLingui } from '@lingui/react';
import { EnvelopeType } from '@prisma/client';
import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
import { injectCss } from '~/utils/css-vars';
import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2';
@@ -26,7 +26,7 @@ export const EmbedSignDocumentV2ClientPage = ({
}: EmbedSignDocumentV2ClientPageProps) => {
const { _ } = useLingui();
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName } =
const { envelope, recipient, envelopeData, setFullName, setEmail, fullName, email } =
useRequiredEnvelopeSigningContext();
const { isCompleted, isRejected, recipientSignature } = envelopeData;
@@ -36,7 +36,9 @@ export const EmbedSignDocumentV2ClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [isEmailLocked, setIsEmailLocked] = useState(envelope.type === EnvelopeType.DOCUMENT);
const [isEmailLocked, setIsEmailLocked] = useState(
envelope.type === EnvelopeType.DOCUMENT && !!email,
);
const onDocumentCompleted = (data: {
token: string;
@@ -128,19 +130,22 @@ export const EmbedSignDocumentV2ClientPage = ({
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (!isCompleted && data.name) {
setFullName(data.name);
// For documents, only use the hash name if the recipient doesn't already have one.
// For templates, always allow the hash name to be used.
if (envelope.type === EnvelopeType.TEMPLATE || !fullName) {
setFullName(data.name);
}
}
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
if (envelope.type === EnvelopeType.TEMPLATE) {
if (!isCompleted && data.email) {
if (!isCompleted && data.email) {
// For documents, only use the hash email if the recipient doesn't already have one.
// For templates, always allow the hash email to be used.
if (envelope.type === EnvelopeType.TEMPLATE || !email) {
setEmail(data.email);
}
if (data.email) {
setIsEmailLocked(!!data.lockEmail);
}
}
@@ -9,6 +9,8 @@ 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 { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
@@ -22,10 +24,11 @@ import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
import { EmbedDocumentFields } from '../embed-document-fields';
@@ -87,14 +90,14 @@ export const MultiSignDocumentSigningView = ({
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
const [pendingFields, completedFields] = [
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
[],
sortFieldsByPosition(
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
[],
),
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ??
[],
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
@@ -178,8 +181,8 @@ export const MultiSignDocumentSigningView = ({
};
return (
<div className="min-h-screen overflow-hidden bg-background">
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
<div className="min-h-screen bg-background">
<div className="relative h-full w-full p-8">
{match({ isLoading, document })
.with({ isLoading: true }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
@@ -227,9 +230,15 @@ export const MultiSignDocumentSigningView = ({
})}
>
<PDFViewerLazy
envelopeItem={document.envelopeItems[0]}
token={token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: document.documentData.id,
version: 'current',
token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => {
setHasDocumentLoaded(true);
onDocumentReady?.();
@@ -360,13 +369,11 @@ export const MultiSignDocumentSigningView = ({
</div>
</div>
)}
</div>
{hasDocumentLoaded && (
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
{hasDocumentLoaded && showPendingFieldTooltip && pendingFields.length > 0 && (
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}
>
<FieldToolTip
key={pendingFields[0].id}
field={pendingFields[0]}
@@ -374,27 +381,27 @@ export const MultiSignDocumentSigningView = ({
>
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
)}
</ElementVisible>
)}
{/* Fields */}
{hasDocumentLoaded && (
<EmbedDocumentFields
fields={pendingFields}
metadata={document.documentMeta}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
)}
{/* Fields */}
{hasDocumentLoaded && (
<EmbedDocumentFields
fields={pendingFields}
metadata={document.documentMeta}
onSignField={onSignField}
onUnsignField={onUnsignField}
/>
)}
{/* Completed fields */}
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
documentMeta={document.documentMeta ?? undefined}
fields={completedFields}
/>
)}
{/* Completed fields */}
{document.status !== DocumentStatus.COMPLETED && (
<DocumentReadOnlyFields
documentMeta={document.documentMeta ?? undefined}
fields={completedFields}
/>
)}
</div>
</>
))
.otherwise(() => null)}
@@ -184,7 +184,10 @@ export const EditorFieldCheckboxForm = ({
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-direction"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Select direction`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -214,7 +217,10 @@ export const EditorFieldCheckboxForm = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-validationRule"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Select at least`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -260,7 +266,10 @@ export const EditorFieldCheckboxForm = ({
void form.trigger();
}}
>
<SelectTrigger className="text-muted-foreground bg-background mt-5 w-full">
<SelectTrigger
data-testid="field-form-validationLength"
className="mt-5 w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Pick a number`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -295,7 +304,7 @@ export const EditorFieldCheckboxForm = ({
<Trans>Checkbox values</Trans>
</p>
<button type="button" onClick={() => addValue()}>
<button type="button" data-testid="field-form-values-add" onClick={() => addValue()}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
@@ -310,7 +319,8 @@ export const EditorFieldCheckboxForm = ({
<FormItem>
<FormControl>
<Checkbox
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
data-testid={`field-form-values-${index}-checked`}
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
@@ -325,7 +335,11 @@ export const EditorFieldCheckboxForm = ({
render={({ field }) => (
<FormItem>
<FormControl>
<Input className="w-full" {...field} />
<Input
data-testid={`field-form-values-${index}-value`}
className="w-full"
{...field}
/>
</FormControl>
</FormItem>
)}
@@ -333,6 +347,7 @@ export const EditorFieldCheckboxForm = ({
<button
type="button"
data-testid={`field-form-values-${index}-remove`}
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
@@ -96,7 +96,7 @@ export const EditorFieldDropdownForm = ({
mode: 'onChange',
defaultValues: {
defaultValue: value.defaultValue,
values: value.values || [{ value: 'Option 1' }],
values: value.values || [{ value: t`Option 1` }],
required: value.required || false,
readOnly: value.readOnly || false,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
@@ -110,13 +110,13 @@ export const EditorFieldDropdownForm = ({
const addValue = () => {
const currentValues = form.getValues('values') || [];
let newValue = 'New option';
let newValue = t`New option`;
// Iterate to create a unique value
for (let i = 0; i < currentValues.length; i++) {
newValue = `New option ${i + 1}`;
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
newValue = `New option ${i + 1}`;
newValue = t`New option ${i + 1}`;
if (currentValues.some((item) => item.value === t`New option ${i + 1}`)) {
newValue = t`New option ${i + 1}`;
} else {
break;
}
@@ -176,7 +176,10 @@ export const EditorFieldDropdownForm = ({
value={field.value ?? '-1'}
onValueChange={(value) => field.onChange(value === '-1' ? undefined : value)}
>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-defaultValue"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Default Value`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -215,7 +218,7 @@ export const EditorFieldDropdownForm = ({
<Trans>Dropdown values</Trans>
</p>
<button type="button" onClick={addValue}>
<button type="button" data-testid="field-form-values-add" onClick={addValue}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
@@ -229,7 +232,7 @@ export const EditorFieldDropdownForm = ({
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input {...field} />
<Input data-testid={`field-form-values-${index}-value`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -238,6 +241,7 @@ export const EditorFieldDropdownForm = ({
<button
type="button"
data-testid={`field-form-values-${index}-remove`}
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
@@ -50,6 +50,7 @@ export const EditorGenericFontSizeField = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-fontSize"
type="number"
min={8}
max={96}
@@ -88,7 +89,7 @@ export const EditorGenericTextAlignField = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectTrigger data-testid="field-form-textAlign">
<SelectValue placeholder={t`Select text align`} />
</SelectTrigger>
<SelectContent>
@@ -131,7 +132,7 @@ export const EditorGenericVerticalAlignField = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectTrigger data-testid="field-form-verticalAlign">
<SelectValue placeholder={t`Select vertical align`} />
</SelectTrigger>
<SelectContent>
@@ -174,6 +175,7 @@ export const EditorGenericLineHeightField = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-lineHeight"
type="number"
min={FIELD_MIN_LINE_HEIGHT}
max={FIELD_MAX_LINE_HEIGHT}
@@ -209,6 +211,7 @@ export const EditorGenericLetterSpacingField = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-letterSpacing"
type="number"
min={FIELD_MIN_LETTER_SPACING}
max={FIELD_MAX_LETTER_SPACING}
@@ -250,12 +253,13 @@ export const EditorGenericRequiredField = ({
<FormControl>
<div className="flex items-center">
<Checkbox
data-testid="field-form-required"
id="field-required"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-required">
<label className="ml-2 text-sm text-muted-foreground" htmlFor="field-required">
<Trans>Required Field</Trans>
</label>
</div>
@@ -293,12 +297,13 @@ export const EditorGenericReadOnlyField = ({
<FormControl>
<div className="flex items-center">
<Checkbox
data-testid="field-form-readOnly"
id="field-read-only"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label className="text-muted-foreground ml-2 text-sm" htmlFor="field-read-only">
<label className="ml-2 text-sm text-muted-foreground" htmlFor="field-read-only">
<Trans>Read Only</Trans>
</label>
</div>
@@ -329,7 +334,7 @@ export const EditorGenericLabelField = ({
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field label`} {...field} />
<Input data-testid="field-form-label" placeholder={t`Field label`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -167,7 +167,12 @@ export const EditorFieldNumberForm = ({
<Trans>Placeholder</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder={t`Placeholder`} {...field} />
<Input
data-testid="field-form-placeholder"
className="bg-background"
placeholder={t`Placeholder`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -183,7 +188,12 @@ export const EditorFieldNumberForm = ({
<Trans>Value</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" placeholder={t`Value`} {...field} />
<Input
data-testid="field-form-value"
className="bg-background"
placeholder={t`Value`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -203,7 +213,10 @@ export const EditorFieldNumberForm = ({
value={field.value === null ? '-1' : field.value}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-numberFormat"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Field format`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -257,6 +270,7 @@ export const EditorFieldNumberForm = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-minValue"
className="bg-background"
placeholder={t`E.g. 0`}
{...field}
@@ -281,6 +295,7 @@ export const EditorFieldNumberForm = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-maxValue"
className="bg-background"
placeholder={t`E.g. 100`}
{...field}
@@ -79,7 +79,7 @@ export const EditorFieldRadioForm = ({
mode: 'onChange',
defaultValues: {
label: value.label || '',
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
values: value.values || [{ id: 1, checked: false, value: t`Default value` }],
required: value.required || false,
readOnly: value.readOnly || false,
direction: value.direction || 'vertical',
@@ -140,7 +140,10 @@ export const EditorFieldRadioForm = ({
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectTrigger
data-testid="field-form-direction"
className="w-full bg-background text-muted-foreground"
>
<SelectValue placeholder={t`Select direction`} />
</SelectTrigger>
<SelectContent position="popper">
@@ -172,7 +175,7 @@ export const EditorFieldRadioForm = ({
<Trans>Radio values</Trans>
</p>
<button type="button" onClick={addValue}>
<button type="button" data-testid="field-form-values-add" onClick={addValue}>
<PlusIcon className="h-4 w-4" />
</button>
</div>
@@ -187,7 +190,8 @@ export const EditorFieldRadioForm = ({
<FormItem>
<FormControl>
<Checkbox
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
data-testid={`field-form-values-${index}-checked`}
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
checked={field.value}
onCheckedChange={(value) => {
// Uncheck all other values.
@@ -216,7 +220,11 @@ export const EditorFieldRadioForm = ({
render={({ field }) => (
<FormItem>
<FormControl>
<Input className="w-full" {...field} />
<Input
data-testid={`field-form-values-${index}-value`}
className="w-full"
{...field}
/>
</FormControl>
</FormItem>
)}
@@ -224,6 +232,7 @@ export const EditorFieldRadioForm = ({
<button
type="button"
data-testid={`field-form-values-${index}-remove`}
className="flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => removeValue(index)}
>
@@ -134,7 +134,7 @@ export const EditorFieldTextForm = ({
<Trans>Label</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field label`} {...field} />
<Input data-testid="field-form-label" placeholder={t`Field label`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -150,7 +150,11 @@ export const EditorFieldTextForm = ({
<Trans>Placeholder</Trans>
</FormLabel>
<FormControl>
<Input placeholder={t`Field placeholder`} {...field} />
<Input
data-testid="field-form-placeholder"
placeholder={t`Field placeholder`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -167,6 +171,7 @@ export const EditorFieldTextForm = ({
</FormLabel>
<FormControl>
<Textarea
data-testid="field-form-text"
className="h-auto"
placeholder={t`Add text to the field`}
{...field}
@@ -200,6 +205,7 @@ export const EditorFieldTextForm = ({
</FormLabel>
<FormControl>
<Input
data-testid="field-form-characterLimit"
className="bg-background"
placeholder={t`Character limit`}
{...field}
+56 -63
View File
@@ -54,8 +54,8 @@ export const ZSignUpFormSchema = z
},
);
export const signupErrorMessages: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signups are disabled.`,
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
};
@@ -130,7 +130,8 @@ export const SignUpForm = ({
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
const errorMessage =
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
toast({
title: _(msg`An error occurred`),
@@ -196,7 +197,7 @@ export const SignUpForm = ({
return (
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
<div className="relative hidden flex-1 overflow-hidden rounded-xl border border-border xl:flex">
<div className="absolute -inset-8 -z-[2] backdrop-blur">
<img
src={communityCardsImage}
@@ -205,17 +206,17 @@ export const SignUpForm = ({
/>
</div>
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
<div className="absolute -inset-8 -z-[1] bg-background/50 backdrop-blur-[2px]" />
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
<div className="rounded-2xl border bg-background px-4 py-1 text-sm font-medium">
<Trans>User profiles are here!</Trans>
</div>
<div className="w-full max-w-md">
<UserProfileTimur
rows={2}
className="bg-background border-border rounded-2xl border shadow-md"
className="rounded-2xl border border-border bg-background shadow-md"
/>
</div>
@@ -223,13 +224,13 @@ export const SignUpForm = ({
</div>
</div>
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
<div className="relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
<div className="h-20">
<h1 className="text-xl font-semibold md:text-2xl">
<Trans>Create a new account</Trans>
</h1>
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
<p className="mt-2 text-xs text-muted-foreground md:text-sm">
<Trans>
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
@@ -323,70 +324,62 @@ export const SignUpForm = ({
/>
{hasSocialAuthEnabled && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>Or</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
</>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="h-px flex-1 bg-border" />
<span className="bg-transparent text-muted-foreground">
<Trans>Or</Trans>
</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
{isGoogleSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
<Trans>Sign Up with Google</Trans>
</Button>
</>
<Button
type="button"
size="lg"
variant={'outline'}
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
<Trans>Sign Up with Google</Trans>
</Button>
)}
{isMicrosoftSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
<Trans>Sign Up with Microsoft</Trans>
</Button>
</>
<Button
type="button"
size="lg"
variant={'outline'}
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignUpWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
<Trans>Sign Up with Microsoft</Trans>
</Button>
)}
{isOIDCSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
<Trans>Sign Up with OIDC</Trans>
</Button>
</>
<Button
type="button"
size="lg"
variant={'outline'}
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignUpWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
<Trans>Sign Up with OIDC</Trans>
</Button>
)}
<p className="text-muted-foreground mt-4 text-sm">
<p className="mt-4 text-sm text-muted-foreground">
<Trans>
Already have an account?{' '}
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
@@ -406,7 +399,7 @@ export const SignUpForm = ({
</Button>
</form>
</Form>
<p className="text-muted-foreground mt-6 text-xs">
<p className="mt-6 text-xs text-muted-foreground">
<Trans>
By proceeding, you agree to our{' '}
<Link
@@ -1,7 +1,11 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRightIcon,
CheckCircle2Icon,
EyeIcon,
EyeOffIcon,
KeyRoundIcon,
Loader2Icon,
RefreshCwIcon,
@@ -32,6 +36,7 @@ type AdminLicenseCardProps = {
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
const { t, i18n } = useLingui();
const [isLicenseKeyVisible, setIsLicenseKeyVisible] = useState(false);
const { license } = licenseData || {};
@@ -53,6 +58,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
<p className="text-sm font-medium text-destructive">
<Trans>Invalid License Key</Trans>
</p>
{/* Don't need to hide invalid license keys. */}
<p className="text-xs text-muted-foreground">{licenseData.requestedLicenseKey}</p>
</>
) : (
@@ -135,7 +141,26 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
<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 className="mt-0.5 flex items-center gap-1">
<p className="min-w-0 break-all text-xs text-muted-foreground">
{isLicenseKeyVisible ? license.licenseKey : '•'.repeat(license.licenseKey.length)}
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground"
aria-label={isLicenseKeyVisible ? t`Hide license key` : t`Show license key`}
onClick={() => setIsLicenseKeyVisible((prevState) => !prevState)}
>
{isLicenseKeyVisible ? (
<EyeOffIcon className="h-3.5 w-3.5" />
) : (
<EyeIcon className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
<div>
@@ -4,6 +4,7 @@ import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { keepPreviousData } from '@tanstack/react-query';
import { CheckIcon, Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router';
@@ -65,14 +66,16 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
const [pages, setPages] = useState<string[]>([]);
const debouncedSearch = useDebouncedValue(search, 200);
const hasValidSearch = debouncedSearch.trim().length > 0;
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
const { data: searchDocumentsData, isFetching: isFetchingDocuments } =
trpcReact.document.search.useQuery(
{
query: debouncedSearch,
},
{
placeholderData: (previousData) => previousData,
enabled: open === true && hasValidSearch,
placeholderData: keepPreviousData,
// Do not batch this due to relatively long request time compared to
// other queries which are generally batched with this.
...SKIP_QUERY_BATCH_META,
@@ -80,6 +83,19 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
},
);
const { data: searchTemplatesData, isFetching: isFetchingTemplates } =
trpcReact.template.search.useQuery(
{
query: debouncedSearch,
},
{
enabled: open === true && hasValidSearch,
placeholderData: keepPreviousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const teamUrl = useMemo(() => {
let teamUrl = currentTeam?.url || null;
@@ -134,17 +150,23 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
];
}, [currentTeam, organisations]);
const searchResults = useMemo(() => {
if (!searchDocumentsData) {
return [];
}
const documentSearchResults =
hasValidSearch && searchDocumentsData
? searchDocumentsData.map((document) => ({
label: document.title,
path: document.path,
value: document.value,
}))
: [];
return searchDocumentsData.map((document) => ({
label: document.title,
path: document.path,
value: document.value,
}));
}, [searchDocumentsData]);
const templateSearchResults =
hasValidSearch && searchTemplatesData
? searchTemplatesData.map((template) => ({
label: template.title,
path: template.path,
value: template.value,
}))
: [];
const currentPage = pages[pages.length - 1];
@@ -222,19 +244,9 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
/>
<CommandList>
{isSearchingDocuments ? (
<CommandEmpty>
<div className="flex items-center justify-center">
<span className="animate-spin">
<Loader />
</span>
</div>
</CommandEmpty>
) : (
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
)}
<CommandEmpty>
<Trans>No results found.</Trans>
</CommandEmpty>
{!currentPage && (
<>
@@ -263,9 +275,27 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
</CommandItem>
</CommandGroup>
{searchResults.length > 0 && (
{(isFetchingDocuments || documentSearchResults.length > 0) && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
<Commands push={push} pages={searchResults} />
{isFetchingDocuments ? (
<div className="flex items-center justify-center py-2">
<Loader className="h-4 w-4 animate-spin" />
</div>
) : (
<Commands push={push} pages={documentSearchResults} />
)}
</CommandGroup>
)}
{(isFetchingTemplates || templateSearchResults.length > 0) && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your templates`)}>
{isFetchingTemplates ? (
<div className="flex items-center justify-center py-2">
<Loader className="h-4 w-4 animate-spin" />
</div>
) : (
<Commands push={push} pages={templateSearchResults} />
)}
</CommandGroup>
)}
</>
@@ -23,7 +23,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { signupErrorMessages } from '~/components/forms/signup';
import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup';
export type ClaimAccountProps = {
defaultName: string;
@@ -90,7 +90,8 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
const errorMessage =
SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST;
toast({
title: _(msg`An error occurred`),
@@ -9,16 +9,17 @@ import { useNavigate, useSearchParams } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TTemplate } from '@documenso/lib/types/template';
import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider';
import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import {
DirectTemplateConfigureForm,
@@ -153,9 +154,15 @@ export const DirectTemplatePageView = ({
<CardContent className="p-2">
<PDFViewerLazy
key={template.id}
envelopeItem={template.envelopeItems[0]}
token={directTemplateRecipient.token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: template.envelopeId,
envelopeItemId: template.envelopeItems[0]?.id,
documentDataId: template.templateDocumentDataId,
version: 'current',
token: directTemplateRecipient.token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
@@ -82,8 +82,6 @@ export const DirectTemplateSigningForm = ({
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const highestPageNumber = Math.max(...localFields.map((field) => field.page));
const fieldsRequiringValidation = useMemo(() => {
return localFields.filter((field) => isFieldUnsignedAndRequired(field));
}, [localFields]);
@@ -250,9 +248,7 @@ export const DirectTemplateSigningForm = ({
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
<Trans>Click to insert field</Trans>
@@ -117,7 +117,7 @@ export const DocumentSigningCompleteDialog = ({
const recipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: {
values: {
name: recipientPayload?.name ?? '',
email: recipientPayload?.email ?? '',
},
@@ -157,6 +157,10 @@ export const DocumentSigningCompleteDialog = ({
}
recipientOverridePayload = recipientForm.getValues();
} else if (recipientPayload && recipientPayload.email && !recipient.email) {
// Form is hidden because we have an email (e.g. from embed context),
// but the DB recipient doesn't have one yet — send the override.
recipientOverridePayload = recipientPayload;
}
// Check if 2FA is required
@@ -9,7 +9,7 @@ import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
@@ -130,11 +130,8 @@ export const DocumentSigningFieldContainer = ({
};
return (
<div className={cn('[container-type:size]')}>
<FieldRootContainer
color={
field.fieldMeta?.readOnly ? RECIPIENT_COLOR_STYLES.readOnly : RECIPIENT_COLOR_STYLES.green
}
<FieldRootContainer
color={getRecipientColorStyles(field.fieldMeta?.readOnly ? 'readOnly' : 0)}
field={field}
>
{!field.inserted && !loading && !readOnlyField && (
@@ -145,54 +142,53 @@ export const DocumentSigningFieldContainer = ({
/>
)}
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)}
{type === 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<button
className="absolute -bottom-10 flex items-center justify-evenly rounded-md border bg-gray-900 opacity-0 group-hover:opacity-100"
onClick={() => void onClearCheckBoxValues(type)}
>
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" />
</span>
</button>
)}
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
</TooltipTrigger>
<TooltipContent
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
sideOffset={2}
>
<span className="rounded-md p-1 text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-100">
<X className="h-4 w-4" />
</span>
</button>
{tooltipText && <p>{tooltipText}</p>}
<Trans>Remove</Trans>
<TooltipArrow />
</TooltipContent>
</Tooltip>
)}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'border border-border bg-foreground/5': !field.inserted,
},
{
'border border-primary bg-documenso-200': field.inserted,
},
)}
>
{field.fieldMeta.label}
</div>
)}
{type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button className="absolute inset-0 z-10" onClick={onRemoveSignedFieldClick}></button>
</TooltipTrigger>
<TooltipContent
className="border-0 bg-orange-300 fill-orange-300 text-orange-900"
sideOffset={2}
>
{tooltipText && <p>{tooltipText}</p>}
<Trans>Remove</Trans>
<TooltipArrow />
</TooltipContent>
</Tooltip>
)}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'bg-foreground/5 border-border border': !field.inserted,
},
{
'bg-documenso-200 border-primary border': field.inserted,
},
)}
>
{field.fieldMeta.label}
</div>
)}
{children}
</FieldRootContainer>
</div>
{children}
</FieldRootContainer>
);
};
@@ -22,6 +22,7 @@ import {
} from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
@@ -30,7 +31,6 @@ import { DocumentReadOnlyFields } from '@documenso/ui/components/document/docume
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
@@ -46,6 +46,7 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
@@ -162,8 +163,6 @@ export const DocumentSigningPageViewV1 = ({
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
const highestPageNumber = Math.max(...fields.map((field) => field.page));
const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted);
const hasPendingFields = pendingFields.length > 0;
@@ -275,10 +274,16 @@ export const DocumentSigningPageViewV1 = ({
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerLazy
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={recipient.token}
version="signed"
key={document.envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: document.envelopeItems[0]?.documentData.id,
version: 'current',
token: recipient.token,
presignToken: undefined,
})}
scrollParentRef="window"
/>
</CardContent>
</Card>
@@ -400,9 +405,7 @@ export const DocumentSigningPageViewV1 = ({
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
)}
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields
.filter(
(field) =>
@@ -1,15 +1,23 @@
import { lazy, useMemo } from 'react';
import { useMemo, useRef, useState } from 'react';
import { Plural, Trans } from '@lingui/react/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { ArrowLeftIcon, BanIcon, DownloadCloudIcon, PaperclipIcon } from 'lucide-react';
import {
ArrowLeftIcon,
BanIcon,
DownloadCloudIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PaperclipIcon,
} from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -23,6 +31,8 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { EnvelopeSignerPageRenderer } from '~/components/general/envelope-signing/envelope-signer-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { BrandingLogo } from '../branding-logo';
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
@@ -33,13 +43,11 @@ import { DocumentSigningMobileWidget } from './document-signing-mobile-widget';
import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
const EnvelopeSignerPageRenderer = lazy(
async () => import('~/components/general/envelope-signing/envelope-signer-page-renderer'),
);
export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const {
isDirectTemplate,
envelope,
@@ -57,6 +65,9 @@ export const DocumentSigningPageViewV2 = () => {
onDocumentRejected,
} = useEmbedSigningContext() || {};
const { t } = useLingui();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
/**
* The total remaining fields remaining for the current recipient or selected assistant recipient.
*
@@ -86,120 +97,159 @@ export const DocumentSigningPageViewV2 = () => {
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="embed--DocumentWidgetContainer hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4 lg:flex">
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.otherwise(() => null)}
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Plural
value={recipientFieldsRemaining.length}
one="1 Field Remaining"
other="# Fields Remaining"
/>
</span>
</h3>
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
<EnvelopeSignerForm />
</div>
</div>
<Separator className="my-6" />
{/* Quick Actions. */}
{!isDirectTemplate && (
<div className="embed--Actions space-y-3 px-4">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Actions</Trans>
</h4>
<DocumentSigningAttachmentsPopover
envelopeId={envelope.id}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<PaperclipIcon className="mr-2 h-4 w-4" />
<Trans>Attachments</Trans>
</Button>
}
/>
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
onRejected={
onDocumentRejected &&
((reason) =>
onDocumentRejected({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
envelopeId: envelope.id,
recipientId: recipient.id,
reason,
}))
}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start hover:text-destructive"
>
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject Document</Trans>
</Button>
}
/>
)}
<div
className={cn(
'embed--DocumentWidgetContainer hidden flex-shrink-0 flex-col border-r border-border bg-background transition-[width] duration-300 lg:flex',
isSidebarCollapsed ? 'w-12' : 'w-80',
)}
>
{isSidebarCollapsed && (
<div className="flex justify-center pt-4">
<Button
variant="ghost"
className="h-7 w-7 p-0"
aria-label={t`Expand sidebar`}
onClick={() => setIsSidebarCollapsed(false)}
>
<PanelLeftOpenIcon className="h-4 w-4" />
</Button>
</div>
)}
<div className="embed--DocumentWidgetFooter mt-auto">
{/* Footer of left sidebar. */}
{!isEmbed && (
<div className="px-4">
<Button asChild variant="ghost" className="w-full justify-start">
<Link to="/">
<ArrowLeftIcon className="mr-2 h-4 w-4" />
<Trans>Return</Trans>
</Link>
</Button>
<div
className={cn(
'flex flex-1 flex-col overflow-hidden py-4',
isSidebarCollapsed && 'invisible w-0',
)}
>
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.otherwise(() => null)}
<div className="ml-2 flex items-center gap-1">
<span className="rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Plural
value={recipientFieldsRemaining.length}
one="1 Field Remaining"
other="# Fields Remaining"
/>
</span>
<Button
variant="ghost"
className="h-7 w-7 p-0"
aria-label={t`Collapse sidebar`}
onClick={() => setIsSidebarCollapsed(true)}
>
<PanelLeftCloseIcon className="h-4 w-4" />
</Button>
</div>
</h3>
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
<EnvelopeSignerForm />
</div>
</div>
<Separator className="my-6" />
{/* Quick Actions. */}
{!isDirectTemplate && (
<div className="embed--Actions space-y-3 px-4">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Actions</Trans>
</h4>
<DocumentSigningAttachmentsPopover
envelopeId={envelope.id}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<PaperclipIcon className="mr-2 h-4 w-4" />
<Trans>Attachments</Trans>
</Button>
}
/>
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
token={recipient.token}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
onRejected={
onDocumentRejected &&
((reason) =>
onDocumentRejected({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
envelopeId: envelope.id,
recipientId: recipient.id,
reason,
}))
}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start hover:text-destructive"
>
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject Document</Trans>
</Button>
}
/>
)}
</div>
)}
<div className="embed--DocumentWidgetFooter mt-auto">
{/* Footer of left sidebar. */}
{!isEmbed && (
<div className="px-4">
<Button asChild variant="ghost" className="w-full justify-start">
<Link to="/">
<ArrowLeftIcon className="mr-2 h-4 w-4" />
<Trans>Return</Trans>
</Link>
</Button>
</div>
)}
</div>
</div>
</div>
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
<div
className="embed--DocumentContainer min-w-0 flex-1 overflow-y-auto"
ref={scrollableContainerRef}
>
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@@ -228,15 +278,16 @@ export const DocumentSigningPageViewV2 = () => {
{/* Document View */}
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
{currentEnvelopeItem ? (
<PDFViewerKonvaLazy
renderer="signing"
<EnvelopePdfViewer
key={currentEnvelopeItem.id}
customPageRenderer={EnvelopeSignerPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.signing}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<p className="text-sm text-foreground">
<Trans>No documents found</Trans>
<Trans>No document selected</Trans>
</p>
</div>
)}
@@ -8,6 +8,7 @@ import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -36,6 +37,7 @@ const ZAttachmentFormSchema = z.object({
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
// NOTE: REMEMBER TO UPDATE THE EMBEDDED VERSION OF THIS COMPONENT TOO.
export const DocumentAttachmentsPopover = ({
envelopeId,
buttonClassName,
@@ -49,9 +51,16 @@ export const DocumentAttachmentsPopover = ({
const utils = trpc.useUtils();
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
});
const { data: attachments } = trpc.envelope.attachment.find.useQuery(
{
envelopeId,
},
{
// Note: The invalidation of the query is manually handled by the onSuccess
// callbacks below for create and delete mutations.
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const { mutateAsync: createAttachment, isPending: isCreating } =
trpc.envelope.attachment.create.useMutation({
@@ -143,7 +152,7 @@ export const DocumentAttachmentsPopover = ({
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
@@ -153,7 +162,7 @@ export const DocumentAttachmentsPopover = ({
{attachments?.data.map((attachment) => (
<div
key={attachment.id}
className="border-border flex items-center justify-between rounded-md border p-2"
className="flex items-center justify-between rounded-md border border-border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p>
@@ -161,7 +170,7 @@ export const DocumentAttachmentsPopover = ({
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
className="truncate text-xs text-muted-foreground underline hover:text-foreground"
>
{attachment.data}
</a>
@@ -1,4 +1,4 @@
import { lazy, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
@@ -9,9 +9,10 @@ import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -21,15 +22,13 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
const EnvelopeGenericPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
);
import { EnvelopeGenericPageRenderer } from '../envelope-editor/envelope-generic-page-renderer';
export type DocumentCertificateQRViewProps = {
documentId: number;
@@ -104,11 +103,13 @@ export const DocumentCertificateQRView = ({
{internalVersion === 2 ? (
<EnvelopeRenderProvider
version="current"
envelope={{
envelopeItems,
id: envelopeItems[0].envelopeId,
status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT,
}}
envelopeItems={envelopeItems}
token={token}
>
<DocumentCertificateQrV2
@@ -150,10 +151,16 @@ export const DocumentCertificateQRView = ({
<div className="mt-12 w-full">
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
key={envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentDataId,
version: 'current',
token,
presignToken: undefined,
})}
scrollParentRef="window"
/>
</div>
</>
@@ -175,7 +182,7 @@ const DocumentCertificateQrV2 = ({
formattedDate,
token,
}: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
const { envelopeItems } = useCurrentEnvelopeRender();
return (
<div className="flex min-h-screen flex-col items-start">
@@ -210,7 +217,11 @@ const DocumentCertificateQrV2 = ({
<div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
<EnvelopePdfViewer
scrollParentRef="window"
customPageRenderer={EnvelopeGenericPageRenderer}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
</div>
</div>
);
@@ -14,6 +14,7 @@ import {
} from '@documenso/lib/constants/trpc';
import type { TDocument } from '@documenso/lib/types/document';
import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -27,10 +28,10 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
export type DocumentEditFormProps = {
@@ -441,10 +442,16 @@ export const DocumentEditForm = ({
>
<CardContent className="p-2">
<PDFViewerLazy
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={undefined}
version="signed"
key={document.envelopeItems[0]?.id}
data={getDocumentDataUrlForPdfViewer({
envelopeId: document.envelopeId,
envelopeItemId: document.envelopeItems[0]?.id,
documentDataId: initialDocument.documentDataId,
version: 'current',
token: undefined,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
@@ -0,0 +1,215 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { nanoid } from '@documenso/lib/universal/id';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EmbeddedEditorAttachmentPopoverProps = {
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
};
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
// NOTE: REMEMBER TO UPDATE THE NON-EMBEDDED VERSION OF THIS COMPONENT TOO.
export const EmbeddedEditorAttachmentPopover = ({
buttonClassName,
buttonSize,
}: EmbeddedEditorAttachmentPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const { envelope, setLocalEnvelope } = useCurrentEnvelopeEditor();
const attachments = envelope.attachments ?? [];
const form = useForm<TAttachmentFormSchema>({
resolver: zodResolver(ZAttachmentFormSchema),
defaultValues: {
label: '',
url: '',
},
});
const onSubmit = (data: TAttachmentFormSchema) => {
setLocalEnvelope({
attachments: [
...attachments,
{
id: nanoid(),
type: 'link',
label: data.label,
data: data.url,
},
],
});
form.reset();
setIsAdding(false);
toast({
title: _(msg`Success`),
description: _(msg`Attachment added successfully.`),
});
};
const onDeleteAttachment = (id: string) => {
setLocalEnvelope({
attachments: attachments.filter((a) => a.id !== id),
});
toast({
title: _(msg`Success`),
description: _(msg`Attachment removed successfully.`),
});
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>
{attachments.length > 0 && <span className="ml-1">({attachments.length})</span>}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
{attachments.length > 0 && (
<div className="space-y-2">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex items-center justify-between rounded-md border border-border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p>
<a
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="truncate text-xs text-muted-foreground underline hover:text-foreground"
>
{attachment.data}
</a>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onDeleteAttachment(attachment.id)}
className="ml-2 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{!isAdding && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsAdding(true)}
>
<Plus className="mr-2 h-4 w-4" />
<Trans>Add Attachment</Trans>
</Button>
)}
{isAdding && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<FormField
control={form.control}
name="label"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder={_(msg`Label`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="url" placeholder={_(msg`URL`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
onClick={() => {
setIsAdding(false);
form.reset();
}}
>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" size="sm" className="flex-1">
<Trans>Add</Trans>
</Button>
</div>
</form>
</Form>
)}
</div>
</PopoverContent>
</Popover>
);
};
@@ -23,7 +23,7 @@ import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { RECIPIENT_COLOR_STYLES } from '@documenso/ui/lib/recipient-colors';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
@@ -175,15 +175,6 @@ export const EnvelopeEditorFieldDragDrop = ({
const { top, left, height, width } = getBoundingClientRect($page);
console.log({
top,
left,
height,
width,
rawPageX: event.pageX,
rawPageY: event.pageY,
});
const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10);
// Calculate x and y as a percentage of the page width and height
@@ -262,9 +253,10 @@ export const EnvelopeEditorFieldDragDrop = ({
};
}, [onMouseClick, onMouseMove, selectedField]);
const selectedRecipientColor = useMemo(() => {
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
}, [selectedRecipientId, getRecipientColorKey]);
const selectedRecipientStyles = useMemo(
() => getRecipientColorStyles(getRecipientColorKey(selectedRecipientId ?? -1)),
[selectedRecipientId, getRecipientColorKey],
);
return (
<>
@@ -278,22 +270,15 @@ export const EnvelopeEditorFieldDragDrop = ({
onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined}
className={cn(
'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors',
selectedRecipientStyles.fieldButton,
)}
>
<p
className={cn(
'text-muted-foreground font-noto group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
'flex items-center justify-center gap-x-1.5 font-noto text-sm font-normal text-muted-foreground group-data-[selected]:text-foreground',
field.className,
{
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
},
selectedRecipientStyles.fieldButtonText,
)}
>
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
@@ -306,8 +291,8 @@ export const EnvelopeEditorFieldDragDrop = ({
{selectedField && (
<div
className={cn(
'text-muted-foreground dark:text-muted-background font-noto pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size]',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
@@ -10,7 +10,10 @@ import { CopyPlusIcon, SquareStackIcon, TrashIcon, UserCircleIcon } from 'lucide
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import {
MIN_FIELD_HEIGHT_PX,
@@ -25,7 +28,7 @@ import { CommandDialog } from '@documenso/ui/primitives/command';
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
import { EnvelopeRecipientSelectorCommand } from './envelope-recipient-selector';
export default function EnvelopeEditorFieldsPageRenderer() {
export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@@ -37,34 +40,22 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer(
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
pageData,
);
const { _className, scale } = pageContext;
const { scale, pageNumber } = pageData;
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageContext.pageNumber],
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const { current: container } = canvasElement;
if (!container) {
return;
}
const isDragEvent = event.type === 'dragend';
const fieldGroup = event.target as Konva.Group;
@@ -344,7 +335,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
// Create a field if no items are selected or the size is too small.
if (
selectedFieldGroups.length === 0 &&
canvasElement.current &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient &&
@@ -531,7 +521,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
removePendingField();
if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) {
if (!currentEnvelopeItem || !editorFields.selectedRecipient) {
return;
}
@@ -546,7 +536,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageContext.pageNumber,
page: pageNumber,
type,
positionX: fieldX,
positionY: fieldY,
@@ -575,10 +565,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
return (
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
<>
{selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current &&
!isFieldChanging && (
@@ -640,17 +627,9 @@ export default function EnvelopeEditorFieldsPageRenderer() {
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
</>
);
}
};
type FieldActionButtonsProps = React.HTMLAttributes<HTMLDivElement> & {
handleDuplicateSelectedFields: () => void;
@@ -1,4 +1,4 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
@@ -6,12 +6,13 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { Link, useRevalidator, useSearchParams } from 'react-router';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import {
FIELD_META_DEFAULT_VALUES,
@@ -29,7 +30,6 @@ import {
} from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
@@ -46,16 +46,14 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
);
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.SIGNATURE]: msg`Signature Settings`,
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
@@ -75,7 +73,9 @@ export const EnvelopeEditorFieldsPage = () => {
const team = useCurrentTeam();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -97,14 +97,10 @@ export const EnvelopeEditorFieldsPage = () => {
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
// Todo: Envelopes - Clean up console logs.
if (!isMetaSame) {
console.log('TRIGGER UPDATE');
editorFields.updateFieldByFormId(selectedField.formId, {
fieldMeta,
});
} else {
console.log('DATA IS SAME, NO UPDATE');
}
};
@@ -156,12 +152,15 @@ export const EnvelopeEditorFieldsPage = () => {
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
<div
className="flex h-full w-full flex-col overflow-y-auto px-2"
ref={scrollableContainerRef}
>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
{/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center">
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
@@ -176,18 +175,17 @@ export const EnvelopeEditorFieldsPage = () => {
</AlertDescription>
</div>
<Button asChild variant="outline">
<Link to={`${relativePath.editorPath}`}>
<Trans>Add Recipients</Trans>
</Link>
<Button variant="outline" onClick={() => void navigateToStep('upload')}>
<Trans>Add Recipients</Trans>
</Button>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy
renderer="editor"
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -249,36 +247,40 @@ export const EnvelopeEditorFieldsPage = () => {
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
@@ -1,7 +1,8 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { DocumentStatus, EnvelopeType, TemplateType } from '@prisma/client';
import {
AlertTriangleIcon,
Building2Icon,
Globe2Icon,
LockIcon,
RefreshCwIcon,
@@ -22,6 +23,7 @@ import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redist
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { EmbeddedEditorAttachmentPopover } from '~/components/general/document/embedded-editor-attachment-popover';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { TemplateDirectLinkBadge } from '../template/template-direct-link-badge';
@@ -30,21 +32,56 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
export default function EnvelopeEditorHeader() {
const { t } = useLingui();
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
useCurrentEnvelopeEditor();
const {
envelope,
isDocument,
isTemplate,
isEmbedded,
updateEnvelope,
autosaveError,
relativePath,
editorConfig,
flushAutosave,
} = useCurrentEnvelopeEditor();
const {
embedded,
general: { allowConfigureEnvelopeTitle },
actions: { allowAttachments, allowDistributing },
} = editorConfig;
const handleCreateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embedded?.onCreate?.(latestEnvelope);
};
const handleUpdateEmbeddedEnvelope = async () => {
const latestEnvelope = await flushAutosave();
embedded?.onUpdate?.(latestEnvelope);
};
return (
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
{editorConfig.embedded?.customBrandingLogo ? (
<img
src={`/api/branding/logo/team/${envelope.teamId}`}
alt="Logo"
className="h-6 w-auto"
/>
) : (
<Link to="/">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center space-x-2">
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT}
disabled={envelope.status !== DocumentStatus.DRAFT || !allowConfigureEnvelopeTitle}
value={envelope.title}
onChange={(title) => {
updateEnvelope({
@@ -58,12 +95,19 @@ export default function EnvelopeEditorHeader() {
{envelope.type === EnvelopeType.TEMPLATE && (
<>
{envelope.templateType === 'PRIVATE' ? (
{envelope.templateType === TemplateType.PRIVATE && (
<Badge variant="secondary">
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
<Trans>Private Template</Trans>
</Badge>
) : (
)}
{envelope.templateType === TemplateType.ORGANISATION && (
<Badge variant="orange">
<Building2Icon className="mr-2 size-4" />
<Trans>Organisation Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.PUBLIC && (
<Badge variant="default">
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
<Trans>Public Template</Trans>
@@ -127,54 +171,75 @@ export default function EnvelopeEditorHeader() {
</div>
<div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
{allowAttachments &&
(isEmbedded ? (
<EmbeddedEditorAttachmentPopover buttonSize="sm" />
) : (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
))}
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
{isDocument && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
)}
{isTemplate && (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
<Button variant="outline" size="sm">
<SettingsIcon className="h-4 w-4" />
</Button>
}
/>
)}
{match({ isEmbedded, isDocument, isTemplate, allowDistributing })
.with({ isEmbedded: false, isDocument: true, allowDistributing: true }, () => (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
</>
))
.with({ isEmbedded: false, isTemplate: true, allowDistributing: true }, () => (
<TemplateUseDialog
envelopeId={envelope.id}
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
templateSigningOrder={envelope.documentMeta?.signingOrder}
recipients={envelope.recipients}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<Trans>Use Template</Trans>
</Button>
}
/>
))
.otherwise(() => null)}
{embedded?.mode === 'create' && (
<Button size="sm" onClick={handleCreateEmbeddedEnvelope}>
{isDocument ? <Trans>Create Document</Trans> : <Trans>Create Template</Trans>}
</Button>
)}
{embedded?.mode === 'edit' && (
<Button size="sm" onClick={handleUpdateEmbeddedEnvelope}>
{isDocument ? <Trans>Update Document</Trans> : <Trans>Update Template</Trans>}
</Button>
)}
</div>
</div>
</nav>
@@ -1,6 +1,6 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import type { Faker } from '@faker-js/faker';
import { Trans } from '@lingui/react/macro';
import { FieldType, SigningStatus } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
@@ -11,33 +11,46 @@ import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeGenericPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
);
// Todo: Envelopes - Dynamically import faker
export const EnvelopeEditorPreviewPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { envelope, editorFields, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'recipient',
);
const [fakerInstance, setFakerInstance] = useState<Faker | null>(null);
useEffect(() => {
void import('@faker-js/faker/locale/en').then((mod) => {
setFakerInstance(mod.faker);
});
}, []);
const fieldsWithPlaceholders = useMemo(() => {
if (!fakerInstance) {
return [];
}
const faker = fakerInstance;
return fields.map((field) => {
const fieldMeta = ZFieldAndMetaSchema.parse(field);
@@ -188,7 +201,7 @@ export const EnvelopeEditorPreviewPage = () => {
.exhaustive(),
};
});
}, [fields, envelope, envelope.recipients, envelope.documentMeta]);
}, [fields, envelope, envelope.recipients, envelope.documentMeta, fakerInstance]);
/**
* Set the selected recipient to the first recipient in the envelope.
@@ -200,37 +213,44 @@ export const EnvelopeEditorPreviewPage = () => {
// Override the parent renderer provider so we can inject custom fields.
return (
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
token={undefined}
fields={fieldsWithPlaceholders}
recipients={envelope.recipients.map((recipient) => ({
...recipient,
signingStatus: SigningStatus.SIGNED,
}))}
presignToken={editorConfig?.embedded?.presignToken}
overrideSettings={{
mode: 'export',
}}
>
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
<div
className="flex h-full w-full flex-col overflow-y-auto px-2"
ref={scrollableContainerRef}
>
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
<Alert variant="warning" className="mx-auto max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
{/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
<div className="mt-4 flex h-full flex-col items-center justify-center">
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy
renderer="editor"
<EnvelopePdfViewer
customPageRenderer={EnvelopeGenericPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.preview}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -21,13 +21,12 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
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 { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
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';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import {
RecipientAutoCompleteInput,
@@ -63,8 +62,14 @@ import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-de
import { useCurrentTeam } from '~/providers/team';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients } =
useCurrentEnvelopeEditor();
const {
envelope,
setRecipientsDebounced,
updateEnvelope,
editorRecipients,
isEmbedded,
editorConfig,
} = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
@@ -72,7 +77,9 @@ export const EnvelopeEditorRecipientForm = () => {
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { user } = useSession();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
@@ -132,7 +139,8 @@ export const EnvelopeEditorRecipientForm = () => {
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1,
enabled: debouncedRecipientSearchQuery.length > 1 && !isEmbedded,
retry: false,
},
);
@@ -603,37 +611,41 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<div className="flex flex-row items-center space-x-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
{editorConfig.recipients?.allowAIDetection && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
)}
<Button
variant="outline"
className="flex flex-row items-center"
size="sm"
disabled={isSubmitting || isUserAlreadyARecipient}
onClick={() => onAddSelfSigner()}
>
<Trans>Add Myself</Trans>
</Button>
{!isEmbedded && (
<Button
variant="outline"
className="flex flex-row items-center"
size="sm"
disabled={isSubmitting || isUserAlreadyARecipient}
onClick={() => onAddSelfSigner()}
>
<Trans>Add Myself</Trans>
</Button>
)}
<Button
variant="outline"
@@ -650,26 +662,32 @@ export const EnvelopeEditorRecipientForm = () => {
</CardHeader>
<CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
{organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<Form {...form}>
<div
className={cn('-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4', {
hidden:
!editorConfig.recipients?.allowConfigureSigningOrder &&
!organisation.organisationClaim.flags.cfr21,
})}
>
{organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<label
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
htmlFor="showAdvancedRecipientSettings"
>
<Trans>Show advanced settings</Trans>
</label>
</div>
)}
<label
className="ml-2 text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
htmlFor="showAdvancedRecipientSettings"
>
<Trans>Show advanced settings</Trans>
</label>
</div>
)}
{editorConfig.recipients?.allowConfigureSigningOrder && (
<FormField
control={form.control}
name="signingOrder"
@@ -728,271 +746,328 @@ export const EnvelopeEditorRecipientForm = () => {
</FormItem>
)}
/>
)}
{isSigningOrderSequential && (
<FormField
control={form.control}
name="allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
disabled={
isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential
}
/>
</FormControl>
{isSigningOrderSequential && (
<FormField
control={form.control}
name="allowDictateNextSigner"
render={({ field: { value, ...field } }) => (
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
<FormControl>
<Checkbox
{...field}
id="allowDictateNextSigner"
checked={value}
onCheckedChange={(checked) => {
field.onChange(checked);
}}
disabled={isSubmitting || hasDocumentBeenSent || !isSigningOrderSequential}
/>
</FormControl>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<div className="flex items-center text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
<FormLabel
htmlFor="allowDictateNextSigner"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans>Allow signers to dictate next signer</Trans>
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the
sequence instead of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the sequence
instead of following the predefined order.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</div>
</FormItem>
)}
/>
)}
</div>
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
<DragDropContext
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
$sensorApi.current = api;
},
]}
>
<Droppable droppableId="signers">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{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,
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,
})}
>
<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,
})}
>
<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>
<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}.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) ||
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}.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) ||
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>
<RecipientRoleSelect
{...field}
hideAssistantRole={
!editorConfig.recipients?.allowAssistantRole
}
hideCCerRole={!editorConfig.recipients?.allowCCerRole}
hideViewerRole={!editorConfig.recipients?.allowViewerRole}
hideApproverRole={
!editorConfig.recipients?.allowApproverRole
}
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 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.email`}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
{!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) ||
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}.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}`}
<RecipientActionAuthSelect
{...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>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
@@ -1005,77 +1080,26 @@ 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>
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{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>
)}
</Droppable>
</DragDropContext>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
</Form>
</AnimateGenericFadeInOut>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
</Form>
<SigningOrderConfirmation
open={showSigningOrderConfirmation}
@@ -1083,13 +1107,15 @@ export const EnvelopeEditorRecipientForm = () => {
onConfirm={handleSigningOrderDisable}
/>
<AiRecipientDetectionDialog
open={isAiDialogOpen}
onOpenChange={onAiDialogOpenChange}
onComplete={onAiDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
{editorConfig.recipients?.allowAIDetection && (
<AiRecipientDetectionDialog
open={isAiDialogOpen}
onOpenChange={onAiDialogOpenChange}
onComplete={onAiDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
)}
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
@@ -0,0 +1,28 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
export const EnvelopeEditorRenderProviderWrapper = ({
children,
token,
presignedToken,
}: {
children: React.ReactNode;
token?: string;
presignedToken?: string;
}) => {
const { envelope } = useCurrentEnvelopeEditor();
return (
<EnvelopeRenderProvider
version="current"
envelope={envelope}
envelopeItems={envelope.envelopeItems}
fields={envelope.fields}
recipients={envelope.recipients}
token={token}
presignToken={presignedToken}
>
{children}
</EnvelopeRenderProvider>
);
};
@@ -1,14 +1,15 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg, t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import {
DocumentDistributionMethod,
DocumentVisibility,
EnvelopeType,
SendStatus,
TemplateType,
} from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
@@ -30,6 +31,7 @@ import {
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import {
ZDocumentAccessAuthTypesSchema,
@@ -65,6 +67,10 @@ import {
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import {
TemplateTypeSelect,
TemplateTypeTooltip,
} from '@documenso/ui/components/template/template-type-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
@@ -101,6 +107,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export const ZAddSettingsFormSchema = z.object({
templateType: z.nativeEnum(TemplateType).optional(),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: z
@@ -175,10 +182,13 @@ export const EnvelopeEditorSettingsDialog = ({
trigger,
...props
}: EnvelopeEditorSettingsDialogProps) => {
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
const { envelope, updateEnvelopeAsync, editorConfig, isEmbedded, organisationEmails } =
useCurrentEnvelopeEditor();
const { settings } = editorConfig;
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@@ -192,6 +202,7 @@ export const EnvelopeEditorSettingsDialog = ({
const createDefaultValues = () => {
return {
templateType: envelope.templateType || TemplateType.PRIVATE,
externalId: envelope.externalId || '',
visibility: envelope.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
@@ -228,12 +239,18 @@ export const EnvelopeEditorSettingsDialog = ({
const emailSettings = form.watch('meta.emailSettings');
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
trpc.enterprise.organisation.email.find.useQuery(
{
organisationId: organisation.id,
perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
enabled: Boolean(organisationEmails !== undefined && organisation.id),
},
);
const emails = emailData?.data || [];
const emails = emailData?.data || organisationEmails || [];
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
@@ -260,6 +277,7 @@ export const EnvelopeEditorSettingsDialog = ({
try {
await updateEnvelopeAsync({
data: {
templateType: envelope.type === EnvelopeType.TEMPLATE ? data.templateType : undefined,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: parsedGlobalAccessAuth.success ? parsedGlobalAccessAuth.data : [],
@@ -285,11 +303,13 @@ export const EnvelopeEditorSettingsDialog = ({
setOpen(false);
toast({
title: t`Success`,
description: t`Envelope updated`,
duration: 5000,
});
if (!isEmbedded) {
toast({
title: t`Success`,
description: t`Envelope updated`,
duration: 5000,
});
}
} catch (err) {
const error = AppError.parseError(err);
@@ -326,7 +346,7 @@ export const EnvelopeEditorSettingsDialog = ({
const selectedTab = tabs.find((tab) => tab.id === activeTab);
if (!selectedTab) {
if (!selectedTab || !settings) {
return null;
}
@@ -347,34 +367,40 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */}
<div className="flex w-80 flex-col border-r bg-accent/20">
<DialogHeader className="p-6 pb-4">
<DialogHeader className="p-6 pb-4" data-testid="envelope-editor-settings-dialog-header">
<DialogTitle>
<Trans>Document Settings</Trans>
</DialogTitle>
</DialogHeader>
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
{tabs.map((tab) => (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
variant="ghost"
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
>
<tab.icon className="mr-2 h-5 w-5" />
{_(tab.title)}
</Button>
))}
{tabs.map((tab) => {
if (tab.id === 'email' && !settings.allowConfigureDistribution) {
return null;
}
return (
<Button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
variant="ghost"
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
>
<tab.icon className="mr-2 h-5 w-5" />
{t(tab.title)}
</Button>
);
})}
</nav>
</div>
{/* Content. */}
<div className="flex w-full flex-col">
<CardHeader className="border-b pb-4">
<CardTitle>{selectedTab ? _(selectedTab.title) : ''}</CardTitle>
<CardDescription>{selectedTab ? _(selectedTab.description) : ''}</CardDescription>
<CardTitle>{selectedTab ? t(selectedTab.title) : ''}</CardTitle>
<CardDescription>{selectedTab ? t(selectedTab.description) : ''}</CardDescription>
</CardHeader>
<Form {...form}>
@@ -384,137 +410,151 @@ export const EnvelopeEditorSettingsDialog = ({
disabled={form.formState.isSubmitting}
key={activeTab}
>
{match(activeTab)
.with('general', () => (
{match({ activeTab, settings })
.with({ activeTab: 'general' }, () => (
<>
<FormField
control={form.control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center">
<Trans>Language</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
{settings.allowConfigureLanguage && (
<FormField
control={form.control}
name="meta.language"
render={({ field }) => (
<FormItem>
<FormLabel className="inline-flex items-center">
<Trans>Language</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
Controls the language for the document, including the language
to be used for email notifications, and the final certificate
that is generated and attached to the document.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
Controls the language for the document, including the language
to be used for email notifications, and the final certificate
that is generated and attached to the document.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormControl>
<Select
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{_(language.full)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{t(language.full)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
{settings.allowConfigureSignatureTypes && (
<FormField
control={form.control}
name="meta.signatureTypes"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allowed Signature Types</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map(
(option) => ({
label: t(option.label),
value: option.value,
}),
)}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={envelopeHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormMessage />
</FormItem>
)}
/>
)}
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
{settings.allowConfigureDateFormat && (
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date Format</Trans>
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={envelopeHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{settings.allowConfigureTimezone && (
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Time Zone</Trans>
</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
value={field.value}
onChange={(value) => value && field.onChange(value)}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="externalId"
@@ -573,233 +613,269 @@ export const EnvelopeEditorSettingsDialog = ({
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<Trans>
This is how the document will reach the recipients once the
document is ready for signing.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the
document to sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - We will generate links which you can
send to the recipients manually.
</Trans>
</li>
</ul>
<Trans>
<strong>Note</strong> - If you use Links in combination with
direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{_(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.envelopeExpirationPeriod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Expiration</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
How long recipients have to complete this document after it is
sent. Uses the team default when set to inherit.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
))
.with('email', () => (
<>
{organisation.organisationClaim.flags.emailDomains && (
{envelope.type === EnvelopeType.TEMPLATE && (
<FormField
control={form.control}
name="meta.emailId"
name="templateType"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
<FormLabel className="flex flex-row items-center">
<Trans>Template type</Trans>
<TemplateTypeTooltip
organisationTeamCount={organisation.teams.length}
/>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
<TemplateTypeSelect
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
{settings.allowConfigureDistribution && (
<FormField
control={form.control}
name="meta.distributionMethod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document Distribution Method</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
</strong>
</h2>
<p>
<Trans>
This is how the document will reach the recipients once the
document is ready for signing.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Email</strong> - The recipient will be emailed the
document to sign, approve, etc.
</Trans>
</li>
<li>
<Trans>
<strong>None</strong> - We will generate links which you
can send to the recipients manually.
</Trans>
</li>
</ul>
<Trans>
<strong>Note</strong> - If you use Links in combination with
direct templates, you will need to manually send the links to
the remaining recipients.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue data-testid="documentDistributionMethodSelectValue" />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
<SelectContent position="popper">
{Object.values(DOCUMENT_DISTRIBUTION_METHODS).map(
({ value, description }) => (
<SelectItem key={value} value={value}>
{t(description)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
)}
{settings.allowConfigureExpirationPeriod && (
<FormField
control={form.control}
name="meta.envelopeExpirationPeriod"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Expiration</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
How long recipients have to complete this document after it is
sent. Uses the team default when set to inherit.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<ExpirationPeriodPicker
value={field.value}
onChange={field.onChange}
disabled={envelopeHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</>
))
.with('security', () => (
.with(
{ activeTab: 'email', settings: { allowConfigureDistribution: true } },
() => (
<>
{settings.allowConfigureEmailSender &&
organisation.organisationClaim.flags.emailDomains && (
<FormField
control={form.control}
name="meta.emailId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email Sender</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{emails.map((email) => (
<SelectItem key={email.id} value={email.id}>
{email.email}
</SelectItem>
))}
<SelectItem value={'-1'}>Documenso</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{settings.allowConfigureEmailReplyTo && (
<FormField
control={form.control}
name="meta.emailReplyTo"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DocumentEmailCheckboxes
value={emailSettings}
onChange={(value) => form.setValue('meta.emailSettings', value)}
/>
</>
),
)
.with({ activeTab: 'security' }, () => (
<>
{organisation.organisationClaim.flags.cfr21 && (
<FormField
@@ -845,30 +921,32 @@ export const EnvelopeEditorSettingsDialog = ({
)}
/>
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document visibility</Trans>
<DocumentVisibilityTooltip />
</FormLabel>
{!isEmbedded && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Document visibility</Trans>
<DocumentVisibilityTooltip />
</FormLabel>
<FormControl>
<DocumentVisibilitySelect
canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={team.currentTeamRole}
{...field}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormControl>
<DocumentVisibilitySelect
canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={team.currentTeamRole}
{...field}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
</>
))
.exhaustive()}
.otherwise(() => null)}
</fieldset>
<div className="flex flex-row justify-end gap-4 p-6">
@@ -9,6 +9,7 @@ export type EnvelopeItemTitleInputProps = {
className?: string;
placeholder?: string;
disabled?: boolean;
dataTestId?: string;
};
export const EnvelopeItemTitleInput = ({
@@ -17,6 +18,7 @@ export const EnvelopeItemTitleInput = ({
className,
placeholder,
disabled,
dataTestId,
}: EnvelopeItemTitleInputProps) => {
const [envelopeItemTitle, setEnvelopeItemTitle] = useState(value);
const [isError, setIsError] = useState(false);
@@ -63,6 +65,7 @@ export const EnvelopeItemTitleInput = ({
{envelopeItemTitle || placeholder}
</span>
<input
data-testid={dataTestId}
data-1p-ignore
autoComplete="off"
ref={inputRef}
@@ -72,7 +75,7 @@ export const EnvelopeItemTitleInput = ({
disabled={disabled}
style={{ width: `${inputWidth}px` }}
className={cn(
'text-foreground hover:outline-muted-foreground focus:outline-muted-foreground rounded-sm border-0 bg-transparent p-1 text-sm font-medium outline-none hover:outline hover:outline-1 focus:outline focus:outline-1',
'rounded-sm border-0 bg-transparent p-1 text-sm font-medium text-foreground outline-none hover:outline hover:outline-1 hover:outline-muted-foreground focus:outline focus:outline-1 focus:outline-muted-foreground',
className,
{
'outline-red-500': isError,
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { DropResult } from '@hello-pangea/dnd';
@@ -8,16 +8,15 @@ import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import {
useCurrentEnvelopeEditor,
useDebounceFunction,
} from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useEnvelopeAutosave } from '@documenso/lib/client-only/hooks/use-envelope-autosave';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
import { nanoid } from '@documenso/lib/universal/id';
import { PRESIGNED_ENVELOPE_ITEM_ID_PREFIX } from '@documenso/lib/utils/embed-config';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
@@ -49,10 +48,22 @@ export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
const {
envelope,
setLocalEnvelope,
editorFields,
editorConfig,
isEmbedded,
navigateToStep,
registerExternalFlush,
registerPendingMutation,
} = useCurrentEnvelopeEditor();
const { envelopeItems: uploadConfig } = editorConfig;
const [localFiles, setLocalFiles] = useState<LocalFile[]>(
envelope.envelopeItems
.sort((a, b) => a.order - b.order)
@@ -103,17 +114,46 @@ export const EnvelopeEditorUploadPage = () => {
);
const onFileDrop = async (files: File[]) => {
const newUploadingFiles: (LocalFile & { file: File })[] = files.map((file) => ({
id: nanoid(),
envelopeItemId: null,
title: file.name,
file,
isUploading: true,
isError: false,
}));
const newUploadingFiles: (LocalFile & {
file: File;
data: TEditorEnvelope['envelopeItems'][number]['data'] | null;
})[] = await Promise.all(
files.map(async (file) => {
return {
id: nanoid(),
envelopeItemId: isEmbedded ? `${PRESIGNED_ENVELOPE_ITEM_ID_PREFIX}${nanoid()}` : null,
title: file.name,
file,
isUploading: isEmbedded ? false : true,
// Clone the buffer so it can be read multiple times (File.arrayBuffer() consumes the stream once)
data: isEmbedded ? new Uint8Array((await file.arrayBuffer()).slice(0)) : null,
isError: false,
};
}),
);
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
// Directly commit the files for embedded documents since those are not uploaded
// until the end of the embedded flow.
if (isEmbedded) {
setLocalEnvelope({
envelopeItems: [
...envelope.envelopeItems,
...newUploadingFiles.map((file) => ({
id: file.envelopeItemId!,
title: file.title,
order: envelope.envelopeItems.length + 1,
envelopeId: envelope.id,
data: file.data!,
documentDataId: '',
})),
],
});
return;
}
const payload = {
envelopeId: envelope.id,
} satisfies TCreateEnvelopeItemsPayload;
@@ -126,7 +166,11 @@ export const EnvelopeEditorUploadPage = () => {
formData.append('files', file);
}
const { data } = await createEnvelopeItems(formData).catch((error) => {
const createPromise = createEnvelopeItems(formData);
registerPendingMutation(createPromise);
const { data } = await createPromise.catch((error) => {
console.error(error);
// Set error state on files in batch upload.
@@ -163,7 +207,9 @@ export const EnvelopeEditorUploadPage = () => {
* Hide the envelope item from the list on deletion.
*/
const onFileDelete = (envelopeItemId: string) => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
setLocalFiles((prev) =>
prev.filter((uploadingFile) => uploadingFile.envelopeItemId !== envelopeItemId),
);
const fieldsWithoutDeletedItem = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId,
@@ -194,18 +240,60 @@ export const EnvelopeEditorUploadPage = () => {
debouncedUpdateEnvelopeItems(items);
};
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
void updateEnvelopeItems({
envelopeId: envelope.id,
data: files
.filter((item) => item.envelopeItemId)
.map((item, index) => ({
envelopeItemId: item.envelopeItemId || '',
order: index + 1,
title: item.title,
})),
});
}, 1000);
const { triggerSave: debouncedUpdateEnvelopeItems, flush: flushUpdateEnvelopeItems } =
useEnvelopeAutosave(
async (files: LocalFile[]) => {
if (isEmbedded) {
const nextEnvelopeItems = files
.filter((item) => item.envelopeItemId)
.map((item, index) => {
const originalEnvelopeItem = envelope.envelopeItems.find(
(envelopeItem) => envelopeItem.id === item.envelopeItemId,
);
return {
id: item.envelopeItemId || '',
title: item.title,
order: index + 1,
envelopeId: envelope.id,
data: originalEnvelopeItem?.data,
documentDataId: originalEnvelopeItem?.documentDataId || '',
};
});
setLocalEnvelope({
envelopeItems: nextEnvelopeItems,
});
return;
}
await updateEnvelopeItems({
envelopeId: envelope.id,
data: files
.filter((item) => item.envelopeItemId)
.map((item, index) => ({
envelopeItemId: item.envelopeItemId || '',
order: index + 1,
title: item.title,
})),
});
},
isEmbedded ? 0 : 1000,
);
const flushUpdateEnvelopeItemsRef = useRef(flushUpdateEnvelopeItems);
flushUpdateEnvelopeItemsRef.current = flushUpdateEnvelopeItems;
// Register the flush callback with the provider so flushAutosave can await
// pending envelope item mutations. We intentionally do NOT unregister on unmount
// because the upload page is unmounted (replaced with a spinner) before
// flushAutosave runs during step transitions. The hook's internal refs survive
// unmounting, so the flush callback remains valid.
useEffect(() => {
registerExternalFlush('envelopeItems', async () => flushUpdateEnvelopeItemsRef.current());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onEnvelopeItemTitleChange = (envelopeItemId: string, title: string) => {
const newLocalFilesValue = localFiles.map((uploadingFile) =>
@@ -277,32 +365,45 @@ export const EnvelopeEditorUploadPage = () => {
</CardHeader>
<CardContent>
<DocumentDropzone
onDrop={onFileDrop}
allowMultiple
className="pb-4 pt-6"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/>
{uploadConfig?.allowUpload && (
<DocumentDropzone
data-testid="envelope-item-dropzone"
onDrop={onFileDrop}
allowMultiple
className="pb-4 pt-6"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
maxFiles={maximumEnvelopeItemCount - localFiles.length}
onDropRejected={onFileDropRejected}
/>
)}
{/* Uploaded Files List */}
<div className="mt-4">
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="files">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="space-y-2">
<div
data-testid="envelope-items-list"
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-2"
>
{localFiles.map((localFile, index) => (
<Draggable
key={localFile.id}
isDragDisabled={isCreatingEnvelopeItems || !canItemsBeModified}
isDragDisabled={
isCreatingEnvelopeItems ||
!canItemsBeModified ||
!uploadConfig?.allowConfigureOrder
}
draggableId={localFile.id}
index={index}
>
{(provided, snapshot) => (
<div
data-testid={`envelope-item-row-${localFile.id}`}
ref={provided.innerRef}
{...provided.draggableProps}
style={provided.draggableProps.style}
@@ -311,18 +412,25 @@ export const EnvelopeEditorUploadPage = () => {
}`}
>
<div className="flex items-center space-x-3">
<div
{...provided.dragHandleProps}
className="cursor-grab active:cursor-grabbing"
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
{uploadConfig?.allowConfigureOrder && (
<div
{...provided.dragHandleProps}
data-testid={`envelope-item-drag-handle-${localFile.id}`}
className="cursor-grab active:cursor-grabbing"
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
</div>
)}
<div>
{localFile.envelopeItemId !== null ? (
<EnvelopeItemTitleInput
disabled={envelope.status !== DocumentStatus.DRAFT}
disabled={
envelope.status !== DocumentStatus.DRAFT ||
!uploadConfig?.allowConfigureTitle
}
value={localFile.title}
dataTestId={`envelope-item-title-input-${localFile.id}`}
placeholder={t`Document Title`}
onChange={(title) => {
onEnvelopeItemTitleChange(localFile.envelopeItemId!, title);
@@ -355,20 +463,36 @@ export const EnvelopeEditorUploadPage = () => {
</div>
)}
{!localFile.isUploading && localFile.envelopeItemId && (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button variant="ghost" size="sm">
<X className="h-4 w-4" />
</Button>
}
/>
)}
{!localFile.isUploading &&
localFile.envelopeItemId &&
uploadConfig?.allowDelete &&
(isEmbedded ? (
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
onClick={() => onFileDelete(localFile.envelopeItemId!)}
>
<X className="h-4 w-4" />
</Button>
) : (
<EnvelopeItemDeleteDialog
canItemBeDeleted={canItemsBeModified}
envelopeId={envelope.id}
envelopeItemId={localFile.envelopeItemId}
envelopeItemTitle={localFile.title}
onDelete={onFileDelete}
trigger={
<Button
variant="ghost"
size="sm"
data-testid={`envelope-item-remove-button-${localFile.id}`}
>
<X className="h-4 w-4" />
</Button>
}
/>
))}
</div>
</div>
)}
@@ -386,13 +510,13 @@ export const EnvelopeEditorUploadPage = () => {
{/* Recipients Section */}
<EnvelopeEditorRecipientForm />
<div className="flex justify-end">
<Button asChild>
<Link to={`${relativePath.editorPath}?step=addFields`}>
{editorConfig.general.allowAddFieldsStep && (
<div className="flex justify-end">
<Button type="button" onClick={() => void navigateToStep('addFields')}>
<Trans>Add Fields</Trans>
</Link>
</Button>
</div>
</Button>
</div>
)}
</div>
);
};
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { motion } from 'framer-motion';
import {
ArrowLeftIcon,
@@ -9,32 +11,30 @@ import {
DownloadCloudIcon,
EyeIcon,
LinkIcon,
MousePointer,
type LucideIcon,
MousePointerIcon,
SendIcon,
SettingsIcon,
Trash2Icon,
Upload,
UploadIcon,
} from 'lucide-react';
import { useNavigate, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
mapSecondaryIdToDocumentId,
mapSecondaryIdToTemplateId,
} from '@documenso/lib/utils/envelope';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateDeleteDialog } from '~/components/dialogs/template-delete-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
@@ -43,52 +43,92 @@ import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
type EnvelopeEditorStepData = {
id: string;
title: MessageDescriptor;
icon: LucideIcon;
description: MessageDescriptor;
};
const envelopeEditorSteps = [
{
id: 'upload',
order: 1,
title: msg`Document & Recipients`,
icon: Upload,
description: msg`Upload documents and add recipients`,
},
{
id: 'addFields',
order: 2,
title: msg`Add Fields`,
icon: MousePointer,
description: msg`Place and configure form fields in the document`,
},
{
id: 'preview',
order: 3,
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
},
];
const UPLOAD_STEP = {
id: 'upload',
title: msg`Document & Recipients`,
icon: UploadIcon,
description: msg`Upload documents and add recipients`,
};
export default function EnvelopeEditor() {
const ADD_FIELDS_STEP = {
id: 'addFields',
title: msg`Add Fields`,
icon: MousePointerIcon,
description: msg`Place and configure form fields in the document`,
};
const PREVIEW_STEP = {
id: 'preview',
title: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
};
export const EnvelopeEditor = () => {
const { t } = useLingui();
const navigate = useNavigate();
const {
envelope,
editorConfig,
isDocument,
isTemplate,
isAutosaving,
flushAutosave,
relativePath,
navigateToStep,
syncEnvelope,
flushAutosave,
resetForms,
} = useCurrentEnvelopeEditor();
const [searchParams, setSearchParams] = useSearchParams();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isStepLoading, setIsStepLoading] = useState(false);
const [currentStep, setCurrentStep] = useState<EnvelopeEditorStep>(() => {
const {
general: {
minimizeLeftSidebar,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
},
actions: {
allowDistributing,
allowDirectLink,
allowDuplication,
allowDownloadPDF,
allowDeletion,
},
} = editorConfig;
const envelopeEditorSteps = useMemo(() => {
const steps: EnvelopeEditorStepData[] = [];
if (allowUploadAndRecipientStep) {
steps.push(UPLOAD_STEP);
}
if (allowAddFieldsStep) {
steps.push(ADD_FIELDS_STEP);
}
if (allowPreviewStep) {
steps.push(PREVIEW_STEP);
}
return steps.map((step, index) => ({
...step,
order: index + 1,
}));
}, [editorConfig]);
const searchParamsStep = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const searchParamStep = searchParams.get('step') as EnvelopeEditorStep | undefined;
// Empty URL param equals upload, otherwise use the step URL param
@@ -103,31 +143,28 @@ export default function EnvelopeEditor() {
}
return 'upload';
});
}, [searchParams]);
const navigateToStep = (step: EnvelopeEditorStep) => {
setCurrentStep(step);
const [pageToRender, setPageToRender] = useState<EnvelopeEditorStep | 'loading'>(
searchParamsStep,
);
void flushAutosave();
const latestStepChangeTime = useRef(0);
if (!isStepLoading && isAutosaving) {
setIsStepLoading(true);
}
const handleStepChange = async (step: EnvelopeEditorStep) => {
setPageToRender('loading');
// Update URL params: empty for upload, otherwise set the step
if (step === 'upload') {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete('step');
return newParams;
});
} else {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.set('step', step);
return newParams;
});
}
const currentTime = Date.now();
latestStepChangeTime.current = currentTime;
await flushAutosave().then(() => {
if (currentTime !== latestStepChangeTime.current) {
return;
}
resetForms();
setPageToRender(step);
});
};
// Watch the URL params and setStep if the step changes.
@@ -136,79 +173,140 @@ export default function EnvelopeEditor() {
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
if (foundStep && foundStep.id !== currentStep) {
if (foundStep && foundStep.id !== pageToRender) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
navigateToStep(foundStep.id as EnvelopeEditorStep);
void handleStepChange(foundStep.id as EnvelopeEditorStep);
}
}, [searchParams]);
useEffect(() => {
if (!isAutosaving) {
setIsStepLoading(false);
}
}, [isAutosaving]);
const currentStepData =
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
envelopeEditorSteps.find((step) => step.id === searchParamsStep) || envelopeEditorSteps[0];
return (
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
<div className="h-screen w-screen bg-envelope-editor-background">
<EnvelopeEditorHeader />
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
<div
className={cn(
'flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4',
{
'w-14': minimizeLeftSidebar,
},
)}
>
{/* Left section step selector. */}
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
{minimizeLeftSidebar ? (
<div className="flex justify-center px-4">
<div className="relative flex h-10 w-10 items-center justify-center">
<svg className="size-10 -rotate-90" viewBox="0 0 40 40" aria-hidden>
{/* Track circle */}
<circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-muted"
/>
{/* Progress arc */}
<motion.circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="text-primary"
strokeDasharray={2 * Math.PI * 16}
initial={false}
animate={{
strokeDashoffset:
2 *
Math.PI *
16 *
(1 - (currentStepData.order ?? 0) / envelopeEditorSteps.length),
}}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-foreground">
<Trans context="The step counter">
{currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</div>
</div>
) : (
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<div className="space-y-3">
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = currentStep === step.id;
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
return (
<div
key={step.id}
className={`cursor-pointer rounded-lg p-3 transition-colors ${
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
/>
</div>
</div>
)}
<div
className={cn('space-y-3', {
'px-4': !minimizeLeftSidebar,
'mt-4 flex flex-col items-center': minimizeLeftSidebar,
})}
>
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = searchParamsStep === step.id;
return (
<button
key={step.id}
data-testid={`envelope-editor-step-${step.id}`}
type="button"
className={cn(
`cursor-pointer rounded-lg text-left transition-colors ${
isActive
? 'border border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border border-gray-200 hover:bg-gray-50 dark:border-gray-400/20 dark:hover:bg-gray-400/10'
}`}
onClick={() => navigateToStep(step.id as EnvelopeEditorStep)}
>
<div className="flex items-center space-x-3">
<div
className={`rounded border p-2 ${
isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`}
>
<Icon
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
/>
</div>
}`,
{
'p-3': !minimizeLeftSidebar,
},
)}
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
>
<div className="flex items-center space-x-3">
<div
className={`rounded border p-2 ${
isActive
? 'border-green-200 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10'
: 'border-gray-100 bg-gray-100 dark:border-gray-400/20 dark:bg-gray-400/10'
}`}
>
<Icon
className={`h-4 w-4 ${isActive ? 'text-green-600' : 'text-gray-600'}`}
/>
</div>
{!minimizeLeftSidebar && (
<div>
<div
className={`text-sm font-medium ${
@@ -221,59 +319,101 @@ export default function EnvelopeEditor() {
</div>
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
</div>
</div>
)}
</div>
);
})}
</div>
</button>
);
})}
</div>
<Separator className="my-6" />
<Separator
className={cn('my-6', {
'mx-auto mb-4 w-4/5': minimizeLeftSidebar,
})}
/>
{/* Quick Actions. */}
<div className="space-y-3 px-4">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Quick Actions</Trans>
</h4>
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SettingsIcon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</Button>
}
/>
<div
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
'px-2': minimizeLeftSidebar,
})}
>
{!minimizeLeftSidebar && (
<h4 className="text-sm font-semibold text-foreground">
<Trans>Quick Actions</Trans>
</h4>
)}
{isDocument && (
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Settings`)}
>
<SettingsIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Document Settings</Trans>
) : (
<Trans>Template Settings</Trans>
)}
</span>
)}
</Button>
}
/>
)}
{isDocument && (
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
</Button>
}
/>
{isDocument && allowDistributing && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Send Envelope`)}
>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Send Document</Trans>
</span>
)}
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Resend Envelope`)}
>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Resend Document</Trans>
</span>
)}
</Button>
}
/>
</>
)}
{/* <Button variant="ghost" size="sm" className="w-full justify-start">
<FileText className="mr-2 h-4 w-4" />
Save as Template
</Button> */}
{isTemplate && (
{isTemplate && allowDirectLink && (
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
@@ -281,100 +421,168 @@ export default function EnvelopeEditor() {
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" />
<Trans>Direct Link</Trans>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Direct Link`)}
>
<LinkIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Direct Link</Trans>
</span>
)}
</Button>
}
/>
)}
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<CopyPlusIcon className="mr-2 h-4 w-4" />
{isDocument ? (
<Trans>Duplicate Document</Trans>
) : (
<Trans>Duplicate Template</Trans>
)}
</Button>
}
/>
{allowDuplication && (
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Duplicate Envelope`)}
>
<CopyPlusIcon className="h-4 w-4" />
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />
<Trans>Download PDF</Trans>
</Button>
}
/>
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Duplicate Document</Trans>
) : (
<Trans>Duplicate Template</Trans>
)}
</span>
)}
</Button>
}
/>
)}
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2Icon className="mr-2 h-4 w-4" />
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
</Button>
{allowDownloadPDF && (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
envelopeItems={envelope.envelopeItems}
trigger={
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Download PDF`)}
>
<DownloadCloudIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Download PDF</Trans>
</span>
)}
</Button>
}
/>
)}
{/* Check envelope ID since it can be in embedded create mode. */}
{allowDeletion && envelope.id && (
<EnvelopeDeleteDialog
id={envelope.id}
type={envelope.type}
status={envelope.status}
title={envelope.title}
canManageDocument={true}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Delete Envelope`)}
>
<Trash2Icon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Delete Document</Trans>
) : (
<Trans>Delete Template</Trans>
)}
</span>
)}
</Button>
}
onDelete={async () => {
await navigate(
envelope.type === EnvelopeType.DOCUMENT
? relativePath.documentRootPath
: relativePath.templateRootPath,
);
}}
/>
)}
</div>
{isDocument ? (
<DocumentDeleteDialog
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
status={envelope.status}
documentTitle={envelope.title}
canManageDocument={true}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.documentRootPath);
}}
/>
) : (
<TemplateDeleteDialog
id={mapSecondaryIdToTemplateId(envelope.secondaryId)}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onDelete={async () => {
await navigate(relativePath.templateRootPath);
}}
/>
)}
{/* Footer of left sidebar. */}
<div className="mt-auto px-4">
<Button variant="ghost" className="w-full justify-start" asChild>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="mr-2 h-4 w-4" />
{isDocument ? (
<Trans>Return to documents</Trans>
) : (
<Trans>Return to templates</Trans>
)}
</Link>
</Button>
</div>
{!editorConfig.embedded && (
<div
className={cn('mt-auto px-4', {
'px-2': minimizeLeftSidebar,
})}
>
<Button
variant="ghost"
className={cn('w-full justify-start', {
'flex items-center justify-center': minimizeLeftSidebar,
})}
asChild
>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? (
<Trans>Return to documents</Trans>
) : (
<Trans>Return to templates</Trans>
)}
</span>
)}
</Link>
</Button>
</div>
)}
</div>
{/* Main Content - Changes based on current step */}
<AnimateGenericFadeInOut className="flex-1 overflow-y-auto" key={currentStep}>
{match({ currentStep, isStepLoading })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
.exhaustive()}
</AnimateGenericFadeInOut>
<div className="flex-1 overflow-y-auto">
{match({
pageToRender,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
})
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => (
<EnvelopeEditorUploadPage />
))
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => (
<EnvelopeEditorFieldsPage />
))
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => (
<EnvelopeEditorPreviewPage />
))
.otherwise(() => null)}
</div>
</div>
</div>
);
}
};
@@ -62,7 +62,12 @@ export const EnvelopeRendererFileSelector = ({
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
return (
<div className={cn('flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4', className)}>
<div
className={cn(
'scrollbar-hidden flex h-fit flex-shrink-0 space-x-2 overflow-x-auto p-4',
className,
)}
>
{envelopeItems.map((doc, i) => (
<EnvelopeItemSelector
key={doc.id}
@@ -5,7 +5,10 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
@@ -15,7 +18,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeGenericPageRenderer() {
export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { i18n } = useLingui();
const {
@@ -28,19 +31,14 @@ export default function EnvelopeGenericPageRenderer() {
overrideSettings,
} = useCurrentEnvelopeRender();
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
},
pageData,
);
const { _className, scale } = pageContext;
const { scale, pageNumber } = pageData;
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
@@ -49,8 +47,7 @@ export default function EnvelopeGenericPageRenderer() {
return fields
.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
)
.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
@@ -73,7 +70,7 @@ export default function EnvelopeGenericPageRenderer() {
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
fieldMeta?.readOnly,
);
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
}, [fields, pageNumber, currentEnvelopeItem?.id, recipients, envelopeStatus]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
if (!pageLayer.current) {
@@ -160,11 +157,9 @@ export default function EnvelopeGenericPageRenderer() {
}
return (
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
<>
{overrideSettings?.showRecipientTooltip &&
pageData.imageLoadingState === 'loaded' &&
localPageFields.map((field) => (
<EnvelopeRecipientFieldTooltip
key={field.id}
@@ -176,14 +171,6 @@ export default function EnvelopeGenericPageRenderer() {
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
</>
);
}
};
@@ -59,12 +59,8 @@ export const EnvelopeRecipientSelector = ({
role="combobox"
className={cn(
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
0,
),
).comboxBoxTrigger,
getRecipientColorStyles(recipients.findIndex((r) => r.id === selectedRecipient?.id))
.comboBoxTrigger,
className,
)}
>
@@ -197,12 +193,8 @@ export const EnvelopeRecipientSelectorCommand = ({
key={recipient.id}
className={cn(
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === recipient.id),
0,
),
).comboxBoxItem,
getRecipientColorStyles(recipients.findIndex((r) => r.id === recipient.id))
.comboBoxItem,
{
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
'cursor-not-allowed': isRecipientDisabled(recipient.id),
@@ -14,7 +14,10 @@ import type { KonvaEventObject } from 'konva/lib/Node';
import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import {
type PageRenderData,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { isBase64Image } from '@documenso/lib/constants/signatures';
@@ -29,7 +32,6 @@ import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
@@ -49,7 +51,7 @@ type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeSignerPageRenderer() {
export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => {
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
@@ -77,17 +79,12 @@ export default function EnvelopeSignerPageRenderer() {
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
const {
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
({ stage, pageLayer }) => createPageCanvas(stage, pageLayer),
pageData,
);
const { _className, scale } = pageContext;
const { scale, pageNumber } = pageData;
const { envelope } = envelopeData;
@@ -99,10 +96,9 @@ export default function EnvelopeSignerPageRenderer() {
}
return fieldsToRender.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
(field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
);
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
}, [recipientFields, selectedAssistantRecipientFields, pageNumber, currentEnvelopeItem?.id]);
/**
* Returns fields that have been fully signed by other recipients for this specific
@@ -117,7 +113,7 @@ export default function EnvelopeSignerPageRenderer() {
return recipient.fields
.filter(
(field) =>
field.page === pageContext.pageNumber &&
field.page === pageNumber &&
field.envelopeItemId === currentEnvelopeItem?.id &&
(field.inserted || field.fieldMeta?.readOnly),
)
@@ -132,7 +128,7 @@ export default function EnvelopeSignerPageRenderer() {
},
}));
});
}, [envelope.recipients, pageContext.pageNumber]);
}, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
@@ -142,13 +138,11 @@ export default function EnvelopeSignerPageRenderer() {
const fieldToRender = ZFullFieldSchema.parse(unparsedField);
let color: TRecipientColor = 'green';
if (fieldToRender.fieldMeta?.readOnly) {
color = 'readOnly';
} else if (showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)) {
color = 'orange';
}
const color = fieldToRender.fieldMeta?.readOnly
? 'readOnly'
: showPendingFieldTooltip && isFieldUnsignedAndRequired(fieldToRender)
? 'orange'
: 'green';
const { fieldGroup } = renderField({
scale,
@@ -534,14 +528,11 @@ export default function EnvelopeSignerPageRenderer() {
}
return (
<div
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
<>
{showPendingFieldTooltip &&
recipientFieldsRemaining.length > 0 &&
recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id &&
recipientFieldsRemaining[0]?.page === pageContext.pageNumber && (
recipientFieldsRemaining[0]?.page === pageNumber && (
<EnvelopeFieldToolTip
key={recipientFieldsRemaining[0].id}
field={recipientFieldsRemaining[0]}
@@ -562,14 +553,6 @@ export default function EnvelopeSignerPageRenderer() {
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas
className={`${_className}__canvas z-0`}
ref={canvasElement}
height={scaledViewport.height}
width={scaledViewport.width}
/>
</div>
</>
);
}
};
@@ -6,6 +6,7 @@ import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
@@ -71,6 +72,14 @@ export const EnvelopeSignerCompleteDialog = () => {
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
// Tooltip not in DOM (page virtualized away) — signal the PDF viewer
// to scroll to the correct page via the data attribute.
const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
if (pdfContent) {
pdfContent.setAttribute('data-scroll-to-page', String(nextField.page));
}
}
},
isEnvelopeItemSwitch ? 150 : 50,
@@ -212,10 +221,12 @@ export const EnvelopeSignerCompleteDialog = () => {
return {
name:
recipient.name ||
fullName ||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
'',
email:
recipient.email ||
email ||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
'',
};

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