Compare commits

..

76 Commits

Author SHA1 Message Date
Lucas Smith eaee0d4bc6 v2.6.0 2026-01-29 18:44:58 +11:00
Lucas Smith 0f8b7670f4 fix: correct path prefix check for static assets caching (#2433) 2026-01-29 16:05:08 +11:00
Catalin Pit 25e148d459 feat: update team member creation dialog with invite functionality (#2366) 2026-01-29 15:15:06 +11:00
David Nguyen 97ceb317a8 fix: license banner not correctly showing (#2432) 2026-01-29 15:09:23 +11:00
David Nguyen c83109628d fix: add license logging (#2431) 2026-01-29 14:08:36 +11:00
David Nguyen a4d0e3e873 fix: resolve safari cert download issues (#2430) 2026-01-29 14:08:07 +11:00
Catalin Pit 59a514c238 feat: allow non-team members as default recipients (#2404) 2026-01-29 13:32:18 +11:00
David Nguyen 1b0df2d082 feat: add license integration (#2346)
Changes:
- Adds integration for the license server.
- Prevent adding flags that the instance is not allowed to add
2026-01-29 13:30:48 +11:00
Catalin Pit d18dcb4d60 feat: autoplace fields from placeholders (#2111)
This PR introduces automatic detection and placement of fields and
recipients based on PDF placeholders.

The placeholders have the following structure:
- `{{fieldType,recipientPosition,fieldMeta}}` 
- `{{text,r1,required=true,textAlign=right,fontSize=50}}`

When the user uploads a PDF document containing such placeholders, they
get converted automatically to Documenso fields and assigned to
recipients.
2026-01-29 13:13:45 +11:00
Konrad d77f81163b fix(i18n): mark missing strings for translation in card components (#2308) 2026-01-29 12:22:07 +11:00
Lahiru Dahampath 62fb9e5248 fix: correct webhook event name in documentation (#2424) 2026-01-29 11:52:36 +11:00
github-actions[bot] 53b0131740 chore: extract translations (#2418) 2026-01-28 21:25:23 +11:00
Catalin Pit 155310b028 feat: add bulk document selection and move functionality (#2387)
This PR introduces bulk actions for documents, allowing users to select
multiple envelopes and perform actions such as moving or deleting 1 or
more documents simultaneously.
2026-01-28 18:27:32 +11:00
Catalin Pit 28bc2dc975 fix: send organisation member removal email to correct user (#2405) 2026-01-28 09:18:58 +02:00
David Nguyen eb3b3b18ce chore: add v1 deprecated docs (#2423) 2026-01-28 14:09:13 +11:00
misha 8bc4f1a713 fix: exclude soft-deleted documents from folder count (#2410) 2026-01-28 13:07:57 +11:00
Timur Ercan d3c898e317 chore: update fair policy with support (#2422)
updated fair policy and added fair self-host support
2026-01-27 17:34:07 +01:00
Lucas Smith d08049ed3b v2.5.1 2026-01-27 20:25:31 +11:00
Lucas Smith 7a583aa7af fix: preserve prompt parameter in OAuth authorize URL builder (#2421)
The prompt option was being discarded for OAuth authorize URLs after
adding support for the NEXT_PRIVATE_OIDC_PROMPT env var. This meant
select_account (used elsewhere) was not being passed through.

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

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

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

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

<img width="887" height="724" alt="image"
src="https://github.com/user-attachments/assets/aeb904c9-515f-49e1-9f8f-513aef455678"
/>
2026-01-13 14:18:10 +11:00
github-actions[bot] cf6f6bcea0 chore: extract translations (#2363) 2026-01-13 12:49:05 +11:00
Catalin Pit 2f27304750 refactor: simplify field dialog component (#2369) 2026-01-13 12:38:10 +11:00
Konrad 912530ca17 fix: mark document visibility options for translation (#2330) 2026-01-12 10:17:03 +11:00
Konrad a995961c4e fix: mark document auth types for translation (#2331) 2026-01-12 09:28:16 +11:00
Lucas Smith 6b041c23b4 v2.4.0 2026-01-08 15:16:57 +11:00
Ted Liang 7b6e948aa2 refactor: reuse svgToPng function (#2365) 2026-01-08 11:30:45 +11:00
Catalin Pit f6d81b22bd docs: update field coordinates documentation and improve devmode (#2359) 2026-01-06 10:29:21 +02:00
Lucas Smith c861dd2ee2 chore: add translations (#2362) 2026-01-06 15:54:54 +11:00
github-actions[bot] 7eabae4b4b chore: extract translations (#2351) 2026-01-06 15:36:46 +11:00
Lucas Smith ae4272a6b6 fix: remove logo from embedded signing v2 page (#2361) 2026-01-06 15:10:58 +11:00
Dylan Tarre fd672943d1 fix: replace hardcoded #7AC455 with text-documenso-700 token (#2358)
Standardizes navigation link colors by replacing hardcoded `#7AC455` hex
values with the existing `text-documenso-700` design token.
2026-01-06 14:58:45 +11:00
David Nguyen c2ea5e5859 fix: migrate certificate generation (#2251)
Generate certificates and audit logs using Konva instead of browserless.

This should:
- Reduce the changes of generations failing
- Improve sealing speed
2026-01-06 14:26:19 +11:00
Grégoire Bécue c1217c5a58 docs: ensure cert directory exists before generating PKCS12 (#2354) 2026-01-03 11:43:55 +11:00
Ted Liang 27eb2d65d4 feat: upgrade alpine and support chromium path (#2353)
Upgrade alpine to 3.22
Support chromium executable path
2026-01-03 11:31:56 +11:00
Catalin Pit ef407cb0b4 refactor: simplify form validation and enhance recipient handling (#2317) 2026-01-02 13:16:45 +11:00
Lucas Smith 1e20561e91 v2.3.2 2025-12-24 16:20:23 +11:00
Lucas Smith a2ec5f0fa1 fix: cleanup konva stages during field insertion (#2347) 2025-12-24 16:09:09 +11:00
Ted Liang de8d13a4c1 fix: hide branding logo in audit log (#2342) 2025-12-24 15:10:13 +11:00
github-actions[bot] 495d61a11d chore: extract translations (#2327) 2025-12-24 13:51:40 +11:00
Catalin Pit 90fdba8000 feat: get many endpoints (#2226)
Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2025-12-24 11:02:02 +11:00
Ephraim Duncan aa1cada79b feat: add find envelopes endpoint (#2244) 2025-12-23 22:51:51 +11:00
Lucas Smith 790b385849 chore: add bundled chromium docker container (#2344)
We use playwright + chromium for certificate generation
and other things.

Self-hosters often have an issue with generating certificates
due to the base image not coming with chromium for size purposes.

This adds a new `-chromium` tag to our docker images for downloading
the larger bundled chromium containers.
2025-12-23 22:09:12 +11:00
Catalin Pit baa2c51123 feat: add delegate document ownership option (#2272)
When using an API key created in a team context, the
documents/templates’ owner always defaults to the team API token
creator, rather than the actual uploader.

For example, John creates the API key for the team "Lawyers". Tom and
Maria use the API key to upload documents. All the uploaded documents
are attributed to John.

This makes it impossible to see who actually uploaded a document.

The new feature allows users to enable document ownership delegation
from the organization/team settings.
2025-12-23 22:08:54 +11:00
Catalin Pit 1e585e06e6 docs: update documentation (#2339) 2025-12-22 15:07:28 +02:00
Ted Liang 5624484631 fix: security CVE-2025-68130 (#2343)
## Description

Fix security
[CVE-2025-68130](https://github.com/advisories/GHSA-43p4-m455-4f4j)
2025-12-22 21:53:49 +11:00
Catalin Pit 810e00da03 feat: add new features to the FEATURES list (#2338) 2025-12-19 10:38:56 +11:00
Lucas Smith eeeee2fa0e v2.3.1 2025-12-18 12:02:04 +11:00
Lucas Smith c50a31a503 fix: use cpu for field rendering (#2337) 2025-12-18 10:48:46 +11:00
Lucas Smith 7360709795 fix: use gemimi 3 flash preview (#2336) 2025-12-18 10:48:16 +11:00
Lucas Smith df678d7d69 v2.3.0 2025-12-17 22:10:47 +11:00
Lucas Smith 6739242554 fix: use cpu for skia-canvas rendering (#2334)
Seems there's a memory leak in gpu rendering with skia canvas
where contexts can live for much longer than expected escaping gc
cleanup

CPU rendering seems better albeit a bit slower.

Synthetic tests were ran with `--expose-gc` to simulate load over time.
2025-12-17 14:48:21 +11:00
Konrad a5e5eecf8b fix: mark links for translation (#2333) 2025-12-17 12:02:12 +11:00
344 changed files with 20557 additions and 4421 deletions
View File
View File
@@ -0,0 +1,161 @@
---
date: 2026-01-28
title: Pdf Placeholder Field Positioning
---
## Overview
This feature enables automatic field placement in PDFs using placeholder text, eliminating the need for manual coordinate-based positioning. It supports two complementary workflows:
1. **Automatic detection on upload** - PDFs containing structured placeholders like `{{signature, r1}}` have fields created automatically when uploaded
2. **API placeholder positioning** - Developers can reference any text in a PDF to position fields instead of calculating coordinates
## Goals
- Allow users to prepare documents in Word/Google Docs with placeholders that become signature fields
- Reduce friction for document preparation workflows
- Provide API developers with a simpler alternative to coordinate-based field positioning
- Support documents with repeated placeholders (e.g., initials on every page)
## Placeholder Format (Automatic Detection)
```
{{FIELD_TYPE, RECIPIENT, option1=value1, option2=value2}}
```
### Components
- **FIELD_TYPE** (required): One of `signature`, `initials`, `name`, `email`, `date`, `text`, `number`, `radio`, `checkbox`, `dropdown`
- **RECIPIENT** (required): `r1`, `r2`, `r3`, etc. - identifies which recipient the field belongs to
- **OPTIONS** (optional): Key-value pairs like `required=true`, `fontSize=14`, `readOnly=true`
### Examples
- `{{signature, r1}}` - Signature field for first recipient
- `{{text, r1, required=true, label=Company Name}}` - Required text field with label
- `{{number, r2, minValue=0, maxValue=100}}` - Number field with validation
### Behavior
- Placeholders without recipient identifiers (e.g., `{{signature}}`) are skipped during automatic detection - reserved for API use
- Invalid field types are silently skipped
- Placeholder text is covered with white rectangles after field creation
## API Placeholder Positioning
The `/api/v2/envelope/field/create-many` endpoint accepts `placeholder` as an alternative to coordinates:
```json
{
"recipientId": 123,
"type": "SIGNATURE",
"placeholder": "{{signature}}"
}
```
### Parameters
| Parameter | Type | Description |
| ------------- | ------- | -------------------------------------------- |
| `placeholder` | string | Text to search for in the PDF |
| `width` | number | Optional override (percentage) |
| `height` | number | Optional override (percentage) |
| `matchAll` | boolean | When true, creates fields at ALL occurrences |
### matchAll Behavior
- Default (`false`): Only first occurrence gets a field
- `true`: Creates a field at every occurrence of the placeholder text
This is useful for documents requiring initials on every page.
## Implementation Components
### Core Functions
- `extractPlaceholdersFromPDF()` - Scans PDF for `{{...}}` patterns with recipient identifiers
- `removePlaceholdersFromPDF()` - Covers placeholder text with white rectangles
- `whiteoutRegions()` - Low-level helper for drawing white boxes on PDF pages
- `parseFieldTypeFromPlaceholder()` - Converts placeholder field type to FieldType enum
- `parseFieldMetaFromPlaceholder()` - Parses options into fieldMeta format
### Integration Points
1. **Upload flow** (`create-envelope.ts`, `create-envelope-items.ts`)
- Extract placeholders at upload time (before saving to storage)
- Pass placeholders in-memory to envelope creation
- Create placeholder recipients if none provided
- Create fields within the same transaction
2. **API field creation** (`create-envelope-fields.ts`)
- Accept `placeholder` as alternative to coordinates
- Search PDF for placeholder text
- Resolve position from bounding box
- Support `matchAll` for multiple occurrences
### Field Meta Parsing
The following properties are explicitly parsed:
- `required`, `readOnly` → boolean
- `fontSize`, `minValue`, `maxValue`, `characterLimit` → number
- Other properties pass through as strings
Note: Signature fields do not support fieldMeta options.
## Testing
### E2E Tests
**UI Tests** (`e2e/auto-placing-fields/`):
- Single recipient placeholder detection
- Multiple recipient placeholder detection
- Field configuration from placeholder options
- Skipping placeholders without recipient identifiers
- Skipping invalid field types
**API Tests** (`e2e/api/v2/placeholder-fields-api.spec.ts`):
- Placeholder-based field positioning
- Width/height overrides
- Error on placeholder not found
- Mixed coordinate and placeholder positioning
- First occurrence only (default)
- All occurrences with `matchAll: true`
## Documentation
### User Documentation
`/users/documents/pdf-placeholders` - Explains:
- Placeholder format and syntax
- Supported field types
- Recipient identifiers
- Available options per field type
- Troubleshooting
### Developer Documentation
`/developers/public-api/reference` - Documents:
- Coordinate-based positioning (existing)
- Placeholder-based positioning (new)
- matchAll parameter
- Mixing both methods
## Edge Cases Handled
1. **No placeholders found** - Original PDF returned unchanged
2. **Placeholder not found (API)** - Returns error with placeholder text
3. **Multiple occurrences** - First only by default, all with `matchAll: true`
4. **No recipient identifier** - Skipped during auto-detection, works for API
5. **Invalid field type** - Skipped during auto-detection
6. **Signature field with options** - Options ignored (signature doesn't support fieldMeta)
## Future Considerations
- Support for placeholder text styles (bold, underline) to indicate field properties
- Template-level placeholder mapping for reusable configurations
- Placeholder validation in document editor before sending
@@ -0,0 +1,76 @@
---
date: 2026-01-26
title: Validate Signer Fields On Distribute
---
## Summary
Validate that signers have at least one signature field before allowing document/envelope distribution via API, matching the existing UI behavior.
## Background
The API originally allowed distributing documents/envelopes without validating that signers had signature fields assigned. This was intentional - we thought API users might have specific flows where this flexibility was needed.
However, after running it this way for a while, we've observed that more often than not, API users inadvertently send documents without fields assigned. This causes confusion for their recipients (who receive a document with nothing to sign) and breaks their own systems expecting a completed signing flow.
## Problem
The API allowed distributing documents/envelopes even when signers had no signature fields assigned. This was inconsistent with the UI which validates this condition before allowing distribution.
## Solution
### 1. Create centralized validation helper
**File**: `packages/lib/utils/recipients.ts`
- Added `RECIPIENT_ROLES_THAT_REQUIRE_FIELDS` constant (currently only `SIGNER`)
- Added `getRecipientsWithMissingFields()` function that returns recipients missing required fields
- Uses existing `isSignatureFieldType` guard from `packages/prisma/guards/is-signature-field.ts`
### 2. Add server-side validation
**File**: `packages/lib/server-only/document/send-document.ts`
- Added validation check that throws `AppError` with `INVALID_REQUEST` code when signers are missing signature fields
- This blocks both v1 and v2 API distribution endpoints since they both use `sendDocument()`
### 3. Fix v1 API error handling
**File**: `packages/api/v1/implementation.ts`
- Changed `sendDocument` endpoint to use `AppError.toRestAPIError(err)` instead of always returning 500
- Now returns 400 for validation errors
### 4. Update UI to use shared helper
**Files**:
- `apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx`
- `packages/ui/primitives/document-flow/add-fields.tsx`
### 5. Consolidate `hasSignatureField` checks
Updated to use `isSignatureFieldType` guard (checks both `SIGNATURE` and `FREE_SIGNATURE`):
- `apps/remix/app/components/general/document-signing/document-signing-form.tsx`
- `apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx`
- `apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx`
- `apps/remix/app/components/embed/embed-direct-template-client-page.tsx`
- `apps/remix/app/components/embed/embed-document-signing-page-v1.tsx`
### 6. Add E2E tests
**Files**:
- `packages/app-tests/e2e/api/v1/document-sending.spec.ts` - 5 new tests
- `packages/app-tests/e2e/api/v2/distribute-validation.spec.ts` - 8 new tests
## Test Coverage
- Distribution fails when signer has no fields
- Distribution fails when signer has only non-signature fields
- Distribution succeeds with SIGNATURE field
- Distribution succeeds with FREE_SIGNATURE field (v1 only via Prisma)
- Distribution succeeds when VIEWER/CC/APPROVER have no fields
- Distribution fails when one of multiple signers is missing signature field
- Distribution succeeds when all signers have signature fields
@@ -0,0 +1,186 @@
---
date: 2026-01-14
title: Simplewebauthn V13 Upgrade
---
## Overview
Upgrade SimpleWebAuthn packages from v9.x to v13.x to address the deprecation of `@simplewebauthn/types` and take advantage of new features and improvements.
## Current State
The codebase currently uses:
- `@simplewebauthn/browser@9.x`
- `@simplewebauthn/server@9.x`
- `@simplewebauthn/types@9.x`
## Breaking Changes Summary (v9 → v13)
### v10.0.0 Breaking Changes
1. **Minimum Node version raised to Node v20**
2. **`generateRegistrationOptions()` now expects `Base64URLString` for `excludeCredentials` IDs** (no more `type: 'public-key'` needed)
3. **`generateAuthenticationOptions()` now expects `Base64URLString` for `allowCredentials` IDs**
4. **`credentialID` returned from verification methods is now `Base64URLString`** instead of `Uint8Array`
5. **`AuthenticatorDevice.credentialID` is now `Base64URLString`**
6. **`rpID` is now required when calling `generateAuthenticationOptions()`**
7. **`generateRegistrationOptions()` will generate random user IDs** if not provided
8. **`user.id` is treated as base64url string in `startRegistration()`**
9. **`userHandle` is treated as base64url string in `startAuthentication()`**
### v11.0.0 Breaking Changes
1. **Positional arguments in `startRegistration()` and `startAuthentication()` replaced by object**
- Before: `startRegistration(options)`
- After: `startRegistration({ optionsJSON: options })`
- Before: `startAuthentication(options)`
- After: `startAuthentication({ optionsJSON: options })`
2. **`AuthenticatorDevice` type renamed to `WebAuthnCredential`**
- `credentialID``credential.id`
- `credentialPublicKey``credential.publicKey`
3. **`verifyRegistrationResponse()` returns `registrationInfo.credential` instead of individual properties**
- `credentialID``credential.id`
- `credentialPublicKey``credential.publicKey`
- `counter``credential.counter`
- `transports` are now in `credential.transports`
4. **`verifyAuthenticationResponse()` uses `credential` argument instead of `authenticator`**
### v13.0.0 Breaking Changes
1. **`@simplewebauthn/types` package is retired**
- Types are now exported from `@simplewebauthn/browser` and `@simplewebauthn/server`
- Import types from `@simplewebauthn/server` instead
## Files to Update
### Package Changes
1. Remove `@simplewebauthn/types` dependency
2. Update `@simplewebauthn/browser` to `^13.2.2`
3. Update `@simplewebauthn/server` to `^13.2.2`
### Server-side Files
#### 1. `packages/lib/server-only/auth/create-passkey-registration-options.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
- Remove `type: 'public-key'` from `excludeCredentials` items
- Update `userID` to use `isoUint8Array.fromUTF8String()` for proper encoding
#### 2. `packages/lib/server-only/auth/create-passkey-authentication-options.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
- Remove `type: 'public-key'` from `allowCredentials` items
#### 3. `packages/lib/server-only/auth/create-passkey-signin-options.ts`
- No changes needed (already using correct options)
#### 4. `packages/lib/server-only/auth/create-passkey.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
- Update to use new `registrationInfo.credential` structure:
- `credentialID``credential.id`
- `credentialPublicKey``credential.publicKey`
- `counter``credential.counter`
- Note: `credential.id` is now a `Base64URLString`, so `Buffer.from(credentialID)` needs updating
#### 5. `packages/lib/server-only/document/is-recipient-authorized.ts`
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`:
- Change `authenticator: { credentialID, credentialPublicKey, counter }` to `credential: { id, publicKey, counter }`
- Since `credential.id` is now base64url string, convert stored `credentialId` buffer to base64url
#### 6. `packages/auth/server/routes/passkey.ts`
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`
- Same changes as `is-recipient-authorized.ts`
#### 7. `packages/trpc/server/auth-router/create-passkey.ts`
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
### Browser-side Files
#### 8. `apps/remix/app/components/dialogs/passkey-create-dialog.tsx`
- Update `startRegistration()` call:
- Before: `startRegistration(passkeyRegistrationOptions)`
- After: `startRegistration({ optionsJSON: passkeyRegistrationOptions })`
#### 9. `apps/remix/app/components/forms/signin.tsx`
- Update `startAuthentication()` call:
- Before: `startAuthentication(options)`
- After: `startAuthentication({ optionsJSON: options })`
#### 10. `apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx`
- Update `startAuthentication()` call:
- Before: `startAuthentication(options)`
- After: `startAuthentication({ optionsJSON: options })`
### Database/Schema Considerations
The database stores `credentialId` as `Bytes`. The new API returns `credential.id` as `Base64URLString`. We need to:
1. When **storing** a new passkey: Convert from `Base64URLString` to `Buffer`
2. When **passing to verification**: Convert from `Buffer` to `Base64URLString`
Use `isoBase64URL` helper from `@simplewebauthn/server/helpers` for these conversions.
## Implementation Steps
### Step 1: Update package.json dependencies
```bash
npm uninstall @simplewebauthn/types
npm install @simplewebauthn/browser@^13.2.2 @simplewebauthn/server@^13.2.2
```
### Step 2: Update type imports
Replace all `@simplewebauthn/types` imports with `@simplewebauthn/server`
### Step 3: Update browser-side API calls
- `startRegistration(options)``startRegistration({ optionsJSON: options })`
- `startAuthentication(options)``startAuthentication({ optionsJSON: options })`
### Step 4: Update server-side registration
- Update `excludeCredentials` format (remove `type: 'public-key'`)
- Update `userID` encoding if needed
- Update `verifyRegistrationResponse()` result handling for new `credential` structure
### Step 5: Update server-side authentication
- Update `allowCredentials` format (remove `type: 'public-key'`)
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`
- Handle `Base64URLString` for `credential.id`
### Step 6: Update credential storage/retrieval
- When storing: Convert `Base64URLString` to `Buffer`
- When reading: Convert `Buffer` to `Base64URLString`
### Step 7: Test passkey flows
1. Test passkey creation
2. Test passkey sign-in
3. Test passkey authentication for document signing
4. Test passkey deletion
## Code Examples
### Converting stored Buffer to Base64URLString for verification
```typescript
import { isoBase64URL } from '@simplewebauthn/server/helpers';
// When reading from database (Buffer) and passing to verification
const credential = {
id: isoBase64URL.fromBuffer(passkey.credentialId),
publicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
transports: passkey.transports,
};
```
### Converting Base64URLString to Buffer for storage
```typescript
import { isoBase64URL } from '@simplewebauthn/server/helpers';
// When storing from registration response
const credentialIdBuffer = Buffer.from(
isoBase64URL.toBuffer(registrationInfo.credential.id)
);
```
## Risks and Mitigations
1. **Database compatibility**: The `credentialId` is stored as `Bytes` in the database. The new API uses `Base64URLString`. We need proper conversion functions.
- **Mitigation**: Use `isoBase64URL.fromBuffer()` and `isoBase64URL.toBuffer()` for conversions
2. **Existing passkeys**: Existing passkeys should continue to work as long as conversion is done correctly.
- **Mitigation**: Test with existing passkeys after upgrade
3. **Browser compatibility**: v10+ requires newer browser APIs.
- **Mitigation**: `browserSupportsWebAuthn()` already handles this check
View File
+15
View File
@@ -1,3 +1,6 @@
# The license key to enable enterprise features for self hosters
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=
# [[AUTH]]
NEXTAUTH_SECRET="secret"
@@ -59,6 +62,18 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH=
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
# OPTIONAL: The path to the certificate chain file for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH=
# OPTIONAL: The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS=
# OPTIONAL: The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH=
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER=
# [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
+22 -3
View File
@@ -3,6 +3,12 @@ name: Publish Docker
on:
push:
branches: ['release']
workflow_dispatch:
inputs:
tag:
description: 'Git tag to build and publish (e.g., v1.0.0)'
required: true
type: string
jobs:
build_and_publish_platform_containers:
@@ -18,6 +24,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
fetch-tags: true
- name: Login to DockerHub
@@ -38,8 +45,11 @@ jobs:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
NEXT_PRIVATE_TELEMETRY_KEY: ${{ secrets.NEXT_PRIVATE_TELEMETRY_KEY }}
NEXT_PRIVATE_TELEMETRY_HOST: ${{ secrets.NEXT_PRIVATE_TELEMETRY_HOST }}
APP_VERSION: ${{ inputs.tag || '' }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
if [ -z "$APP_VERSION" ]; then
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
fi
GIT_SHA="$(git rev-parse HEAD)"
docker build \
@@ -73,6 +83,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
fetch-tags: true
- name: Login to DockerHub
@@ -89,8 +100,12 @@ jobs:
password: ${{ secrets.GH_TOKEN }}
- name: Create and push DockerHub manifest
env:
APP_VERSION: ${{ inputs.tag || '' }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
if [ -z "$APP_VERSION" ]; then
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
fi
GIT_SHA="$(git rev-parse HEAD)"
# Check if the version is stable (no rc or beta in the version)
@@ -126,8 +141,12 @@ jobs:
docker manifest push documenso/documenso:$APP_VERSION
- name: Create and push Github Container Registry manifest
env:
APP_VERSION: ${{ inputs.tag || '' }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
if [ -z "$APP_VERSION" ]; then
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
fi
GIT_SHA="$(git rev-parse HEAD)"
# Check if the version is stable (no rc or beta in the version)
+4
View File
@@ -63,3 +63,7 @@ CLAUDE.md
# scripts
scripts/output*
# license
.documenso-license.json
.documenso-license-backup.json
+80
View File
@@ -0,0 +1,80 @@
---
description: Add and commit changes using conventional commits
allowed-tools: Bash, Read, Glob, Grep
---
Create a git commit for the current changes using the Conventional Commits standard.
## Process
1. **Analyze the changes** by running:
- `git status` to see all modified/untracked files
- `git diff` to see unstaged changes
- `git diff --staged` to see already-staged changes
- `git log --oneline -5` to see recent commit style
2. **Stage appropriate files**:
- Stage all related changes with `git add`
- Do NOT stage files that appear to contain secrets (.env, credentials, API keys, tokens)
- If you detect potential secrets, warn the user and skip those files
3. **Determine the commit type** based on the changes:
- `feat`: New feature or capability
- `fix`: Bug fix
- `docs`: Documentation only
- `style`: Formatting, whitespace (not CSS)
- `refactor`: Code restructuring without behavior change
- `perf`: Performance improvement
- `test`: Adding or updating tests
- `build`: Build system or dependencies
- `ci`: CI/CD configuration
- `chore`: Maintenance tasks, tooling, config
NOTE: Do not use a scope for commits
4. **Write the commit message**:
- **Subject line**: `<type>: <description>`
- Use imperative mood ("add" not "added")
- Lowercase, no period at end
- Max 50 characters if possible, 72 hard limit
- **Body** (if needed): Explain _why_, not _what_
- Wrap at 72 characters
- Separate from subject with blank line
## Commit Format
```
<type>[scope]: <subject>
[optional body explaining WHY this change was made]
```
## Examples
Simple change:
```
fix: handle empty input in parser without throwing
```
With body:
```
feat: add streaming response support
Large responses were causing memory issues in production.
Streaming allows processing chunks incrementally.
```
## Rules
- NEVER commit files that may contain secrets
- NEVER use `git commit --amend` unless the user explicitly requests it
- NEVER use `--no-verify` to skip hooks
- If the pre-commit hook fails, fix the issues and create a NEW commit
- If there are no changes to commit, inform the user and stop
- Use a HEREDOC to pass the commit message to ensure proper formatting
## Execute
Run the git commands to analyze, stage, and commit the changes now.
+112
View File
@@ -0,0 +1,112 @@
---
description: Continue implementing a spec from a previous session
argument-hint: <spec-file-path>
---
You are continuing implementation of a specification that was started in a previous session. Work autonomously until the feature is complete and tests pass.
## Your Task
1. **Read the spec** at `$ARGUMENTS`
2. **Read CODE_STYLE.md** for formatting conventions
3. **Assess current state**:
- Check git status for uncommitted changes
- Run tests to see what's passing/failing (if E2E tests exist)
- Review any existing implementation
4. **Determine what remains** by comparing the spec to the current state
5. **Plan remaining work** using TodoWrite
6. **Continue implementing** until complete
## Assessing Current State
Run these commands to understand where the previous session left off:
```bash
git status # See uncommitted changes
git log --oneline -10 # See recent commits
npm run typecheck -w @documenso/remix # Check for type errors
npm run lint:fix # Check for linting issues
```
Review the code that's already been written to understand:
- What's already implemented
- What's partially done
- What's not started yet
## Implementation Guidelines
### During Implementation
- Follow CODE_STYLE.md strictly (2-space indent, double quotes, braces always, etc.)
- Follow workspace rules for TypeScript, React, TRPC patterns, and Remix conventions
- Mark todos complete as you finish each task
- Commit logical chunks of work
### Code Quality
- No stubbed implementations
- Handle edge cases and error conditions
- Include descriptive error messages with context
- Use async/await for all I/O operations
- Use AppError class when throwing errors
- Use Zod for validation and react-hook-form for forms
### Testing
**Important**: E2E tests are time-consuming. Only write tests for non-trivial functionality.
- Write E2E tests in `packages/app-tests/e2e/` using Playwright
- Test critical user flows and edge cases
- Follow existing E2E test patterns in the codebase
- Use descriptive test names that explain what is being tested
- Skip tests for trivial changes (simple UI tweaks, minor refactors, etc.)
## Autonomous Workflow
Work continuously through these steps:
1. **Implement** - Write the code for the current task
2. **Typecheck** - Run `npm run typecheck -w @documenso/remix` to verify types
3. **Lint** - Run `npm run lint:fix` to fix linting issues
4. **Test** - If non-trivial, run E2E tests: `npm run test:dev -w @documenso/app-tests`
5. **Fix** - If tests fail, fix and re-run
6. **Repeat** - Move to next task
## Stopping Conditions
**Stop and report success when:**
- All spec requirements are implemented
- Typecheck passes
- Lint passes
- E2E tests pass (if written for non-trivial functionality)
**Stop and ask for help when:**
- The spec is ambiguous and you need clarification
- You encounter a blocking issue you cannot resolve
- You need to make a decision that significantly deviates from the spec
- External dependencies are missing
## Commands
```bash
# Type checking
npm run typecheck -w @documenso/remix
# Linting
npm run lint:fix
# E2E Tests (only for non-trivial work)
npm run test:dev -w @documenso/app-tests # Run E2E tests in dev mode
npm run test-ui:dev -w @documenso/app-tests # Run E2E tests with UI
npm run test:e2e # Run full E2E test suite
# Development
npm run dev # Start dev server
```
## Begin
Read the spec file and CODE_STYLE.md, assess the current implementation state, then continue where the previous session left off. Use TodoWrite to track your progress throughout.
@@ -0,0 +1,75 @@
---
description: Create a new justification file in .agents/justifications/
argument-hint: <justification-slug> [content]
---
You are creating a new justification file in the `.agents/justifications/` directory.
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the justification content
3. **Create the file** - Use the create-justification script to generate the file
## Usage
The script will automatically:
- Generate a unique three-word ID (e.g., `swift-emerald-river`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/justifications/`
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-justification.ts "$ARGUMENTS" "Your justification content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-justification.ts "$ARGUMENTS" << HEREDOC
Your multi-line
justification content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-justification.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Justification Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `architecture-decision``Architecture Decision`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `tech-stack-choice`, `api-design-rationale`)
- Include clear reasoning and context for the decision
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
## Begin
Create a justification file using the slug from `$ARGUMENTS` and appropriate content documenting the reasoning or justification.
+76
View File
@@ -0,0 +1,76 @@
---
description: Create a new plan file in .agents/plans/
argument-hint: <plan-slug> [content]
---
You are creating a new plan file in the `.agents/plans/` directory.
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the plan content
3. **Create the file** - Use the create-plan script to generate the file
## Usage
The script will automatically:
- Generate a unique three-word ID (e.g., `happy-blue-moon`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/plans/`
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-plan.ts "$ARGUMENTS" "Your plan content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-plan.ts "$ARGUMENTS" << HEREDOC
Your multi-line
plan content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-plan.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Plan Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `my-feature``My Feature`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `user-authentication`, `api-integration`)
- Include clear, actionable plan content
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
## Begin
Create a plan file using the slug from `$ARGUMENTS` and appropriate content for the planning task.
+75
View File
@@ -0,0 +1,75 @@
---
description: Create a new scratch file in .agents/scratches/
argument-hint: <scratch-slug> [content]
---
You are creating a new scratch file in the `.agents/scratches/` directory.
## Your Task
1. **Determine the slug** - Use `$ARGUMENTS` as the file slug (kebab-case recommended)
2. **Gather content** - Collect or generate the scratch content
3. **Create the file** - Use the create-scratch script to generate the file
## Usage
The script will automatically:
- Generate a unique three-word ID (e.g., `calm-teal-cloud`)
- Create frontmatter with current date and formatted title
- Save the file as `{id}-{slug}.md` in `.agents/scratches/`
## Creating the File
### Option 1: Direct Content
If you have the content ready, run:
```bash
npx tsx scripts/create-scratch.ts "$ARGUMENTS" "Your scratch content here"
```
### Option 2: Multi-line Content (Heredoc)
For multi-line content, use heredoc:
```bash
npx tsx scripts/create-scratch.ts "$ARGUMENTS" << HEREDOC
Your multi-line
scratch content
goes here
HEREDOC
```
### Option 3: Pipe Content
You can also pipe content:
```bash
echo "Your content" | npx tsx scripts/create-scratch.ts "$ARGUMENTS"
```
## File Format
The created file will have:
```markdown
---
date: 2026-01-13
title: Scratch Title
---
Your content here
```
The title is automatically formatted from the slug (e.g., `quick-notes``Quick Notes`).
## Guidelines
- Use descriptive slugs in kebab-case (e.g., `exploration-ideas`, `temporary-notes`)
- Scratch files are for temporary notes, explorations, or ideas
- The unique ID ensures no filename conflicts
- Files are automatically dated for organization
## Begin
Create a scratch file using the slug from `$ARGUMENTS` and appropriate content for notes or exploration.
+201
View File
@@ -0,0 +1,201 @@
---
description: Generate MDX documentation for a module or feature
argument-hint: <module-path-or-feature>
---
You are creating proper MDX documentation for a module or feature in Documenso using Nextra.
## Your Task
1. **Identify the scope** - What does `$ARGUMENTS` refer to? (file, directory, or feature name)
2. **Read the source code** - Understand the public API, types, and behavior
3. **Read existing docs** - Check if there's documentation to update or reference
4. **Write comprehensive documentation** - Create or update MDX docs in the appropriate location
5. **Update navigation** - Add entry to `_meta.js` if creating a new page
## Documentation Structure
Create documentation in the appropriate location:
- **Developer docs**: `apps/documentation/pages/developers/`
- **User docs**: `apps/documentation/pages/users/`
### File Format
All documentation files must be `.mdx` files with frontmatter:
```mdx
---
title: Page Title
description: Brief description for SEO and meta tags
---
# Page Title
Content starts here...
```
### Navigation
Each directory should have a `_meta.js` file that defines the navigation structure:
```javascript
export default {
index: 'Introduction',
'feature-name': 'Feature Name',
'another-feature': 'Another Feature',
};
```
If creating a new page, add it to the appropriate `_meta.js` file.
### Documentation Format
````mdx
---
title: <Module|Feature Name>
description: Brief description of what this does and when to use it
---
# <Module|Feature Name>
Brief description of what this module/feature does and when to use it.
## Installation
If there are specific packages or imports needed:
```bash
npm install @documenso/package-name
```
## Quick Start
```jsx
// Minimal working example
import { Component } from '@documenso/package';
const Example = () => {
return <Component />;
};
```
## API Reference
### Component/Function Name
Description of what it does.
#### Props/Parameters
| Prop/Param | Type | Description |
| ---------- | -------------------- | ------------------------- |
| prop | `string` | Description of the prop |
| optional | `boolean` (optional) | Optional prop description |
#### Example
```jsx
import { Component } from '@documenso/package';
<Component prop="value" optional={true} />;
```
### Types
#### `TypeName`
```typescript
type TypeName = {
property: string;
optional?: boolean;
};
```
## Examples
### Common Use Case
```jsx
// Full working example
```
### Advanced Usage
```jsx
// More complex example
```
## Related
- [Link to related documentation](/developers/path)
- [Another related page](/users/path)
````
## Guidelines
### Content Quality
- **Be accurate** - Verify behavior by reading the code
- **Be complete** - Document all public API surface
- **Be practical** - Include real, working examples
- **Be concise** - Don't over-explain obvious things
- **Be user-focused** - Write for the target audience (developers or users)
### Code Examples
- Use appropriate language tags: `jsx`, `tsx`, `typescript`, `bash`, `json`
- Show imports when not obvious
- Include expected output in comments where helpful
- Progress from simple to complex
- Use real examples from the codebase when possible
### Formatting
- Always include frontmatter with `title` and `description`
- Use proper markdown headers (h1 for title, h2 for sections)
- Use tables for props/parameters documentation (matching existing style)
- Use code fences with appropriate language tags
- Use Nextra components when appropriate:
- `<Callout type="info">` for notes
- `<Steps>` for step-by-step instructions
- Use relative links for internal documentation (e.g., `/developers/embedding/react`)
### Nextra Components
You can import and use Nextra components:
```jsx
import { Callout, Steps } from 'nextra/components';
<Callout type="info">
This is an informational note.
</Callout>
<Steps>
<Steps.Step>First step</Steps.Step>
<Steps.Step>Second step</Steps.Step>
</Steps>
```
### Maintenance
- Include types inline so docs don't get stale
- Reference source file locations for complex behavior
- Keep examples up-to-date with the codebase
- Update `_meta.js` when adding new pages
## Process
1. **Explore the code** - Read source files to understand the API
2. **Identify the audience** - Is this for developers or users?
3. **Check existing docs** - Look for similar pages to match style
4. **Draft the structure** - Outline sections before writing
5. **Write content** - Fill in each section with frontmatter
6. **Add examples** - Create working code samples
7. **Update navigation** - Add to `_meta.js` if needed
8. **Review** - Read through for clarity and accuracy
## Begin
Analyze `$ARGUMENTS`, read the relevant source code, check existing documentation patterns, and create comprehensive MDX documentation following the Documenso documentation style.
+100
View File
@@ -0,0 +1,100 @@
---
description: Implement a spec from the plans directory
argument-hint: <spec-file-path>
---
You are implementing a specification from the `.agents/plans/` directory. Work autonomously until the feature is complete and tests pass.
## Your Task
1. **Read the spec** at `$ARGUMENTS`
2. **Read CODE_STYLE.md** for formatting conventions
3. **Plan the implementation** using the TodoWrite tool to break down the work
4. **Implement the feature** following the spec and code style
5. **Write E2E tests** only for non-trivial functionality (E2E tests are time-consuming)
6. **Run tests** and fix any failures
7. **Run typecheck and lint** and fix any issues
## Implementation Guidelines
### Before Coding
- Understand the spec's goals and scope
- Identify the desired API from usage examples in the spec
- Review related existing code to understand patterns
- Break the work into discrete tasks using TodoWrite
### During Implementation
- Follow CODE_STYLE.md strictly (2-space indent, double quotes, braces always, etc.)
- Follow workspace rules for TypeScript, React, TRPC patterns, and Remix conventions
- Mark todos complete as you finish each task
- Commit logical chunks of work
### Code Quality
- No stubbed implementations
- Handle edge cases and error conditions
- Include descriptive error messages with context
- Use async/await for all I/O operations
- Use AppError class when throwing errors
- Use Zod for validation and react-hook-form for forms
### Testing
**Important**: E2E tests are time-consuming. Only write tests for non-trivial functionality.
- Write E2E tests in `packages/app-tests/e2e/` using Playwright
- Test critical user flows and edge cases
- Follow existing E2E test patterns in the codebase
- Use descriptive test names that explain what is being tested
- Skip tests for trivial changes (simple UI tweaks, minor refactors, etc.)
## Autonomous Workflow
Work continuously through these steps:
1. **Implement** - Write the code for the current task
2. **Typecheck** - Run `npm run typecheck -w @documenso/remix` to verify types
3. **Lint** - Run `npm run lint:fix` to fix linting issues
4. **Test** - If non-trivial, run E2E tests: `npm run test:dev -w @documenso/app-tests`
5. **Fix** - If tests fail, fix and re-run
6. **Repeat** - Move to next task
## Stopping Conditions
**Stop and report success when:**
- All spec requirements are implemented
- Typecheck passes
- Lint passes
- E2E tests pass (if written for non-trivial functionality)
**Stop and ask for help when:**
- The spec is ambiguous and you need clarification
- You encounter a blocking issue you cannot resolve
- You need to make a decision that significantly deviates from the spec
- External dependencies are missing
## Commands
```bash
# Type checking
npm run typecheck -w @documenso/remix
# Linting
npm run lint:fix
# E2E Tests (only for non-trivial work)
npm run test:dev -w @documenso/app-tests # Run E2E tests in dev mode
npm run test-ui:dev -w @documenso/app-tests # Run E2E tests with UI
npm run test:e2e # Run full E2E test suite
# Development
npm run dev # Start dev server
```
## Begin
Read the spec file and CODE_STYLE.md, then start implementing. Use TodoWrite to track your progress throughout.
+57
View File
@@ -0,0 +1,57 @@
---
description: Deep-dive interview to flesh out a spec or design document
agent: build
argument-hint: <file-path>
---
You are conducting a thorough interview to help flesh out and complete a specification or design document.
## Your Task
1. **Read the document** at `$ARGUMENTS`
2. **Analyze it deeply** - identify gaps, ambiguities, unexplored edge cases, and areas needing clarification
3. **Interview the user** by providing a question with some pre-determined options
4. **Write the completed spec** back to the file when the interview is complete
## Interview Guidelines
### Question Quality
- Ask **non-obvious, insightful questions** - avoid surface-level queries
- Focus on: technical implementation details, architectural decisions, edge cases, error handling, UX implications, security considerations, performance tradeoffs, integration points, migration strategies, rollback plans
- Each question should reveal something that would otherwise be missed
- Challenge assumptions embedded in the document
- Explore second and third-order consequences of design decisions
- Use the Web Search and other tools where required to ground questions (e.g. package recommendations)
### Question Strategy
- Start by identifying the 3-5 most critical unknowns or ambiguities
- Use the AskUserQuestion tool with well-crafted options that represent real tradeoffs
- When appropriate, offer multiple valid approaches with their pros/cons as options
- Don't ask about things that are already clearly specified
- Probe deeper when answers reveal new areas of uncertainty
### Topics to Explore (as relevant)
- **Technical**: Data models, API contracts, state management, concurrency, caching, validation
- **UX**: Error states, loading states, empty states, edge cases, accessibility, mobile considerations
- **Operations**: Deployment, monitoring, alerting, debugging, logging, feature flags
- **Security**: Auth, authz, input validation, rate limiting, audit trails
- **Scale**: Performance bottlenecks, data growth, traffic spikes, graceful degradation
- **Integration**: Dependencies, backwards compatibility, versioning, migration path
- **Failure modes**: What happens when X fails? How do we recover? What's the blast radius?
### Interview Flow
1. Ask 2-4 questions at a time (use multiple questions in one when they're related)
2. After each round, incorporate answers and identify follow-up questions
3. Continue until all critical areas are addressed
4. Signal when you believe the interview is complete, but offer to go deeper
## Output
When the interview is complete:
1. Synthesize all gathered information
2. Rewrite/expand the original document with the new details
3. Preserve the document's original structure where sensible, but reorganize if needed
4. Add new sections for areas that weren't originally covered
5. Write the completed spec back to `$ARGUMENTS`
Begin by reading the file and identifying your first set of deep questions.
@@ -0,0 +1,56 @@
---
name: create-justification
description: Create a new justification file in .agents/justifications/ with a unique three-word ID, frontmatter, and formatted title
license: MIT
compatibility: opencode
metadata:
audience: agents
workflow: decision-making
---
## What I do
I help you create new justification files in the `.agents/justifications/` directory. Each justification file gets:
- A unique three-word identifier (e.g., `swift-emerald-river`)
- Frontmatter with the current date and formatted title
- Content you provide
## How to use
Run the script with a slug and content:
```bash
npx tsx scripts/create-justification.ts "decision-name" "Justification content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-justification.ts "decision-name" << HEREDOC
Multi-line
justification content
goes here
HEREDOC
```
## File format
Files are created as: `{three-word-id}-{slug}.md`
Example: `swift-emerald-river-decision-name.md`
The file includes frontmatter:
```markdown
---
date: 2026-01-13
title: Decision Name
---
Your content here
```
## When to use me
Use this skill when you need to document the reasoning or justification for a decision, approach, or architectural choice. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
+56
View File
@@ -0,0 +1,56 @@
---
name: create-plan
description: Create a new plan file in .agents/plans/ with a unique three-word ID, frontmatter, and formatted title
license: MIT
compatibility: opencode
metadata:
audience: agents
workflow: planning
---
## What I do
I help you create new plan files in the `.agents/plans/` directory. Each plan file gets:
- A unique three-word identifier (e.g., `happy-blue-moon`)
- Frontmatter with the current date and formatted title
- Content you provide
## How to use
Run the script with a slug and content:
```bash
npx tsx scripts/create-plan.ts "feature-name" "Plan content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-plan.ts "feature-name" << HEREDOC
Multi-line
plan content
goes here
HEREDOC
```
## File format
Files are created as: `{three-word-id}-{slug}.md`
Example: `happy-blue-moon-feature-name.md`
The file includes frontmatter:
```markdown
---
date: 2026-01-13
title: Feature Name
---
Your content here
```
## When to use me
Use this skill when you need to create a new plan document for a feature, task, or project. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
+56
View File
@@ -0,0 +1,56 @@
---
name: create-scratch
description: Create a new scratch file in .agents/scratches/ with a unique three-word ID, frontmatter, and formatted title
license: MIT
compatibility: opencode
metadata:
audience: agents
workflow: exploration
---
## What I do
I help you create new scratch files in the `.agents/scratches/` directory. Each scratch file gets:
- A unique three-word identifier (e.g., `calm-teal-cloud`)
- Frontmatter with the current date and formatted title
- Content you provide
## How to use
Run the script with a slug and content:
```bash
npx tsx scripts/create-scratch.ts "note-name" "Scratch content here"
```
Or use heredoc for multi-line content:
```bash
npx tsx scripts/create-scratch.ts "note-name" << HEREDOC
Multi-line
scratch content
goes here
HEREDOC
```
## File format
Files are created as: `{three-word-id}-{slug}.md`
Example: `calm-teal-cloud-note-name.md`
The file includes frontmatter:
```markdown
---
date: 2026-01-13
title: Note Name
---
Your content here
```
## When to use me
Use this skill when you need to create a temporary note, exploration document, or scratch pad for ideas. The unique ID ensures no filename conflicts, and the frontmatter provides metadata for organization.
+2 -1
View File
@@ -17,5 +17,6 @@
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
},
"prisma.pinToPrisma6": true
}
+2
View File
@@ -11,6 +11,8 @@
- `npm run format` - Format code with Prettier
- `npm run dev` - Start development server for Remix app
**Important:** Do not run `npm run build` to verify changes unless explicitly asked. Builds take a long time (~2 minutes). Use `npx tsc --noEmit` for type checking specific packages if needed.
## Code Style Guidelines
- Use TypeScript for all code; prefer `type` over `interface`
+50
View File
@@ -52,3 +52,53 @@ You can build the project with:
```bash
npm run build
```
## AI-Assisted Development with OpenCode
We use [OpenCode](https://opencode.ai) for AI-assisted development. OpenCode provides custom commands and skills to help maintain consistency and streamline common workflows.
OpenCode works with most major AI providers (Anthropic, OpenAI, Google, etc.) or you can use [Zen](https://opencode.ai/zen) for optimized coding models. Configure your preferred provider in the OpenCode settings.
> **Important**: All AI-generated code must be thoroughly reviewed by the contributor before submitting a PR. You are responsible for understanding and validating every line of code you submit. If we detect that contributors are simply throwing AI-generated code over the wall without proper review, they will be blocked from the repository.
### Getting Started
1. Install OpenCode (see [opencode.ai](https://opencode.ai) for other install methods):
```bash
curl -fsSL https://opencode.ai/install | bash
```
2. Configure your AI provider (or use Zen for optimized models)
3. Run `opencode` in the project root
### Available Commands
Use these commands in OpenCode by typing the command name:
| Command | Description |
| ------------------------------ | -------------------------------------------------------- |
| `/implement <spec-path>` | Implement a spec from `.agents/plans/` autonomously |
| `/continue <spec-path>` | Continue implementing a spec from a previous session |
| `/interview <file-path>` | Deep-dive interview to flesh out a spec or design |
| `/document <module-path>` | Generate MDX documentation for a module or feature |
| `/commit` | Create a conventional commit for staged changes |
| `/create-plan <slug>` | Create a new plan file in `.agents/plans/` |
| `/create-scratch <slug>` | Create a scratch file for notes in `.agents/scratches/` |
| `/create-justification <slug>` | Create a justification file in `.agents/justifications/` |
### Typical Workflow
1. **Create a plan**: Use `/create-plan my-feature` to draft a spec for a new feature
2. **Flesh out the spec**: Use `/interview .agents/plans/<file>.md` to refine requirements
3. **Implement**: Use `/implement .agents/plans/<file>.md` to build the feature
4. **Continue if needed**: Use `/continue .agents/plans/<file>.md` to pick up where you left off
5. **Commit**: Use `/commit` to create a conventional commit
### Agent Files
The `.agents/` directory stores AI-generated artifacts:
- **`.agents/plans/`** - Feature specs and implementation plans
- **`.agents/scratches/`** - Temporary notes and explorations
- **`.agents/justifications/`** - Decision rationale and technical justifications
These files use a unique ID format (`{word}-{word}-{word}-{slug}.md`) to prevent conflicts.
@@ -5,14 +5,22 @@ description: Learn how to get the coordinates of a field in a document.
## Field Coordinates
Field coordinates represent the position of a field in a document. They are returned in the `pageX` and `pageY` properties of the field.
Field coordinates represent the position of a field in a document. They are returned in the `pageX`, `pageY`, `width` and `height` properties of the field.
To enable field coordinates, you can use the `devmode` query parameter.
```bash
https://app.documenso.com/documents/<document-id>/edit?devmode=true
# Legacy editor
https://app.documenso.com/t/<team-url>/documents/<envelope-id>/legacy_editor?devmode=true
```
You should then see the coordinates on top of each field.
![Field Coordinates Legacy Editor](/developer-mode/field-coordinates-legacy-editor.webp)
![Field Coordinates](/developer-mode/field-coordinates.webp)
```bash
# New editor
https://app.documenso.com/t/<team-url>/documents/<envelope-id>/edit?step=addFields&devmode=true
```
![Field Coordinates New Editor](/developer-mode/field-coordinates-new-editor.webp)
@@ -61,6 +61,6 @@ You can access the following services:
- Main application - http://localhost:3000
- Incoming Mail Access - http://localhost:9000
- Database Connection Details:
- Port: 54320
- Connection: Use your favourite database client to connect to the database.
- Port: 54320
- Connection: Use your favorite database client to connect to the database.
- S3 Storage Dashboard - http://localhost:9001
@@ -31,9 +31,18 @@ Our new API V2 supports the following typed SDKs:
## API V1 - Deprecated
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
<Callout type="warning">
<strong>API V1 is deprecated.</strong>
<br />
The V1 API will continue to be supported for the foreseeable future, but it is limited to
<strong>Legacy Documents</strong> (Documents created using the old non-envelope editor).
📖 [Documentation](https://documen.so/api-v2-docs)
<strong>Important:</strong> To work with the new <strong>Envelope</strong> document system, you
must use the
<strong> V2 API</strong>.
</Callout>
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
## Availability
@@ -1,3 +1,8 @@
---
title: Rate Limits
description: Learn about the rate limits for the Documenso Public API.
---
import { Callout } from 'nextra/components';
# Rate Limits
@@ -316,6 +316,8 @@ Before adding fields to an envelope, you will need the following details:
See the [Get Envelope](#get-envelope) section for more details on how to retrieve these details.
### Coordinate-Based Positioning
The following is an example of a request which creates 2 new fields on the first page of the envelope.
Note that width, height, positionX and positionY are percentage numbers between 0 and 100, which scale the field relative to the size of the PDF.
@@ -360,6 +362,95 @@ curl https://app.documenso.com/api/v2/envelope/field/create-many \
}'
```
### Placeholder-Based Positioning
Instead of specifying exact coordinates, you can position fields using placeholder text in the PDF. The API will search for the text and place the field at that location.
This is useful when:
- You have PDFs with designated placeholder text (e.g., `{{signature}}`, `[SIGN HERE]`)
- You want field positions to adapt to document content changes
- You're working with templated documents generated from other systems
```sh
curl https://app.documenso.com/api/v2/envelope/field/create-many \
--request POST \
--header 'Authorization: api_xxxxxxxxxxxxxx' \
--header 'Content-Type: application/json' \
--data '{
"envelopeId": "envelope_xxxxxxxxxx",
"data": [
{
"recipientId": recipient_id_here,
"type": "SIGNATURE",
"placeholder": "{{signature}}"
},
{
"recipientId": recipient_id_here,
"type": "NAME",
"placeholder": "{{name}}",
"width": 30,
"height": 5
}
]
}'
```
#### Placeholder Parameters
| Parameter | Type | Required | Description |
| ------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| `placeholder` | string | Yes | Text to search for in the PDF. The field is placed at the location of this text. |
| `width` | number | No | Override the field width (percentage). If omitted, uses the placeholder text width. |
| `height` | number | No | Override the field height (percentage). If omitted, uses the placeholder text height. |
| `matchAll` | boolean | No | When `true`, creates a field at every occurrence of the placeholder. Default is `false` (first occurrence only). |
<Callout type="info">
The placeholder text is automatically covered with a white rectangle after field creation, so it
won't appear in the final signed document.
</Callout>
#### Multiple Occurrences
If your PDF contains the same placeholder text multiple times (e.g., initials on every page), use `matchAll: true` to create fields at all occurrences:
```json
{
"recipientId": 123,
"type": "INITIALS",
"placeholder": "{{initials}}",
"matchAll": true
}
```
This will create one INITIALS field for each occurrence of `{{initials}}` in the PDF.
#### Mixing Positioning Methods
You can combine coordinate-based and placeholder-based positioning in the same request:
```json
{
"envelopeId": "envelope_xxxxxxxxxx",
"data": [
{
"recipientId": 123,
"type": "SIGNATURE",
"placeholder": "{{signature}}"
},
{
"recipientId": 123,
"type": "DATE",
"page": 1,
"positionX": 70,
"positionY": 85,
"width": 20,
"height": 3
}
]
}
```
Field meta allows you to further configure fields, for example it will allow you to add multiple items for checkboxes or radios.
A successful request will return a JSON response with the newly added fields.
@@ -148,6 +148,7 @@ This method avoids file permission issues by creating the certificate directly i
# Generate certificate inside container using environment variable
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
mkdir -p /app/certs && \
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout /tmp/private.key \
-out /tmp/certificate.crt \
@@ -290,10 +291,13 @@ For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)](
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
@@ -53,15 +53,21 @@ Have the Certificate Authority sign the Certificate Signing Request.
Configure your instance to use the new certificate by configuring the following environment variables in your `.env` file:
| Environment Variable | Description |
| :-------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_ APPLICATION_CREDENTIALS_CONTENTS` | The Google Cloud Credentials file path for the gcloud-hsm signing transport. This field is optional. |
| Environment Variable | Description |
| :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport. This field is optional. |
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. This field is optional. |
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. This field is optional. |
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. This field is optional. |
</Steps>
@@ -1,3 +1,8 @@
---
title: Telemetry
description: Learn about the telemetry data that Documenso collects from self-hosted instances.
---
# Telemetry
Documenso collects anonymous telemetry data from self-hosted instances to help us understand how the software is being used and make improvements to the product. This telemetry is enabled by default, but you can easily disable it if you prefer.
@@ -5,7 +5,7 @@ description: Learn how to use webhooks to receive real-time notifications about
# Webhooks
Webhooks are HTTP callbacks triggered by specific events. When the user subscribes to a specific event, and that event occurs, the webhook makes an HTTP request to the URL provided by the user. The request can be a simple notification or carry a payload with more information about the event.
Webhooks are HTTP callbacks triggered by specific events. When you subscribe to a specific event and that event occurs, the webhook makes an HTTP request to the URL you provide. The request can be a simple notification or carry a payload with more information about the event.
Some of the common use cases for webhooks include:
@@ -25,13 +25,13 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
## Create a webhook subscription
You can create a webhook subscription from the user settings page. Click on your avatar in the top right corner of the dashboard and select "**[User settings](https://app.documenso.com/settings)**" from the dropdown menu.
You can create a webhook subscription from the team settings page. Click your avatar in the top right corner of the dashboard and select "Team settings" from the dropdown menu.
![A screenshot of the Documenso's dashboard that shows the dropdown menu when you click on your user avatar](/webhook-images/dashboard-user-dropdown-menu.webp)
![A screenshot of the Documenso's dashboard that shows the dropdown menu when you click on your user avatar](/webhook-images/documenso-main-page.webp)
Then, navigate to the "**[Webhooks](https://app.documenso.com/settings/webhooks)**" tab, where you can see a list of your existing webhooks and create new ones.
Then, navigate to the "Webhooks" tab, which takes you to the webhooks main page.
![A screenshot of the Documenso's user settings page that shows the Webhooks tab and the Create Webhook button](/webhook-images/webhooks-settings-page.webp)
![A screenshot of the Documenso's team settings page that shows the Webhooks tab and the Create Webhook button](/webhook-images/webhooks-main-page.webp)
Clicking on the "**Create Webhook**" button opens a modal to create a new webhook subscription.
@@ -41,7 +41,7 @@ To create a new webhook subscription, you need to provide the following informat
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp)
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/create-webhook-dialog.webp)
After you have filled in the required information, click on the "**Create Webhook**" button to save your subscription.
@@ -49,7 +49,22 @@ The screenshot below illustrates a newly created webhook subscription.
![A screenshot of the Documenso's user settings page that shows the newly created webhook subscription](/webhook-images/webhooks-page.webp)
You can edit or delete your webhook subscriptions by clicking the "**Edit**" or "**Delete**" buttons next to the webhook.
You can edit, view the logs, or delete your webhook subscriptions by clicking the three dots (...) under the "Action" column. You can also access the webhook logs by clicking on the webhook subscription directly.
![A screenshot of the Documenso's team settings page that shows the webhook logs](/webhook-images/webhook-detail-page.webp)
You can go even further and check the execution details of each call by clicking on a specific webhook call.
![A screenshot of the Documenso's team settings page that shows the webhook call details](/webhook-images/webhook-run-page.webp)
This page shows the details of the webhook call such as:
- status
- event
- date when the webhook was sent
- response code
- request body
- response body and headers
## Webhook fields
@@ -529,7 +544,7 @@ Example payload for the `document.rejected` event:
}
```
Example payload for the `document.rejected` event:
Example payload for the `document.cancelled` event:
```json
{
@@ -619,18 +634,26 @@ Example payload for the `document.rejected` event:
}
```
## Webhook Events Testing
## Webhook events testing
You can trigger test webhook events to test the webhook functionality. To trigger a test webhook, navigate to the [Webhooks page](/developers/webhooks) and click on the "Test Webhook" button.
You can trigger test webhook events to test the webhook functionality. To do so, navigate to the webhook subscription details page and click the "Test" button.
![Documenso's Webhooks Page](/webhook-images/test-webhooks-page.webp)
![A screenshot of the Documenso's team settings page that shows the webhook logs](/webhook-images/webhook-detail-page.webp)
This opens a dialog where you can select the event type to test.
![Documenso's individual webhook page](/webhook-images/test-webhook-dialog.webp)
![Documenso's individual webhook page](/webhook-images/webhook-test-trigger.webp)
Choose the appropriate event and click "Send Test Webhook." Youll shortly receive a test payload from Documenso with sample data.
Choose the event you want to test and click "Send". Youll then receive a test payload from Documenso with sample data.
## Webhook events resending
To resend a webhook call, you need to navigate to the webhook call page and click the "Resend" button.
![A screenshot of the Documenso's team settings page that shows the webhook call details](/webhook-images/webhook-run-page.webp)
This will send the webhook event to the webhook URL again.
## Availability
Webhooks are available to individual users and teams.
Webhooks are available to teams only.
@@ -1,3 +1,8 @@
---
title: Signature Levels
description: Learn about the different signature levels for Documenso.
---
import { Callout } from 'nextra/components';
# Signature Levels
@@ -26,20 +31,20 @@ ensures the legal validity and enforceability of electronic signatures and recor
### Main Requirements
- [x] Intent to Sign: "Parties must demonstrate their intent to sign [..]"
- [x] Consent: "The ESIGN Act requires that all parties involved in a transaction consent to the use of electronic signatures and records [..]"
- [x] Consumer Disclosures: Before obtaining their consent, financial institutions must provide the consumer a clear and conspicuous statement informing the consumer [..]
- [x] Record Retention: Electronic Records must be maintained for later access by signers.
- [x] Security: The ESIGN Act does not mandate specific security measures, but it does require that parties take reasonable steps to ensure the security and integrity of electronic signatures and records. This may include implementing encryption, access controls, and authentication measures.
- [x] **Intent to Sign**: "Parties must demonstrate their intent to sign [..]"
- [x] **Consent**: "The ESIGN Act requires that all parties involved in a transaction consent to the use of electronic signatures and records [..]"
- [x] **Consumer Disclosures**: Before obtaining their consent, financial institutions must provide the consumer a clear and conspicuous statement informing the consumer [..]
- [x] **Record Retention**: Electronic Records must be maintained for later access by signers.
- [x] **Security**: The ESIGN Act does not mandate specific security measures, but it does require that parties take reasonable steps to ensure the security and integrity of electronic signatures and records. This may include implementing encryption, access controls, and authentication measures.
## UETA (Uniform Electronic Transactions Act)
<Callout type="info" emoji="✅">
Status: Compliant
</Callout>
The Uniform Electronic Transactions Act is a law that provides a legal framework for the use of electronic
signatures and records in electronic transactions, ensuring they have the same validity and enforceability
as paper documents and handwritten signatures.
The Uniform Electronic Transactions Act is a law that provides a legal framework for the use of
electronic signatures and records in electronic transactions, ensuring they have the same validity
and enforceability as paper documents and handwritten signatures.
### Main Requirements
@@ -50,9 +55,9 @@ _See [ESIGN](/users/compliance/signature-levels#-esign-electronic-signatures-in-
<Callout type="info" emoji="✅">
Status: Compliant for Level 1 - SES (Simple Electronic Signatures)
</Callout>
eIDAS (Electronic Identification, Authentication and Trust Services) is an EU regulation that standardizes
electronic identification and trust services for secure and seamless electronic transactions across European
member states.
eIDAS (Electronic Identification, Authentication and Trust Services) is an EU regulation that
standardizes electronic identification and trust services for secure and seamless electronic
transactions across European member states.
### Level 1 - SES (Simple Electronic Signatures)
@@ -69,8 +74,8 @@ eIDAS SES (Simple Electronic Signature) is a basic electronic signature with min
Status: [Planned](https://github.com/documenso/backlog/issues/9) via third party until [Let's
Sign](https://github.com/documenso/backlog/issues/21) is realized.
</Callout>
eIDAS AES (Advanced Electronic Signature) provides a higher level of security with unique identification
of the signer and data integrity.
eIDAS AES (Advanced Electronic Signature) provides a higher level of security with unique
identification of the signer and data integrity.
### Main Requirements
@@ -85,8 +90,8 @@ of the signer and data integrity.
Status: [Planned](https://github.com/documenso/backlog/issues/32) via third party until [Let's
Sign](https://github.com/documenso/backlog/issues/21) is realized.
</Callout>
eIDAS QES (Qualified Electronic Signature) is the highest security level, legally equivalent to a handwritten
signature within the EU.
eIDAS QES (Qualified Electronic Signature) is the highest security level, legally equivalent to a
handwritten signature within the EU.
### Main Requirements
@@ -1,3 +1,8 @@
---
title: Standards and Regulations
description: Learn about the different standards and regulations for Documenso.
---
import { Callout } from 'nextra/components';
## 21 CFR Part 11
@@ -3,6 +3,8 @@ export default {
'document-preferences': 'Document Preferences',
'document-visibility': 'Document Visibility',
fields: 'Document Fields',
'pdf-placeholders': 'PDF Placeholders',
'email-preferences': 'Email Preferences',
'ai-detection': 'AI Recipient & Field Detection',
'default-recipients': 'Default Recipients',
};
@@ -0,0 +1,45 @@
---
title: Default Document Recipients
description: Learn how to set default recipients with various roles for your documents.
---
import { Callout, Steps } from 'nextra/components';
# Default Document Recipients
Documenso allows you to set default recipients for your documents. This is useful when you require specific recipients to be added to every document you send.
You can add default recipients with the same roles as the recipients you can add when sending a document:
- **Signer** - The recipient will be required to sign the document.
- **Approver** - The recipient will be required to approve the document.
- **Viewer** - The recipient will be required to view the document.
- **CC** - The recipient will receive a copy of the document.
You can set default recipients at the organisation or team level.
### Organisation level
To set default recipients at the organisation level, navigate to the organisation settings page and click the "Document" tab under the "Preferences" section.
Then scroll down to the "Default Recipients" section and add the recipients you want to be included in every document you send.
![A screenshot of the organisation's default recipients page](/default-recipients/organisation-default-recipients-select-step.webp)
The recipients are added with the "CC" role by default, but you can select a different role for each recipient.
![A screenshot of the organisation's default recipients page when selecting the role of the recipient](/default-recipients/organisation-default-recipients-role-step.webp)
### Team level
Setting the default recipients at the team level follows the same process as setting them at the organisation level.
<Callout type="info">
Setting the default recipients at the team level will override organisation-level defaults.
</Callout>
To set default recipients at the team level, navigate to the team settings page and click the "Document" tab under the "Preferences" section.
Then scroll down to the "Default Recipients" section. By default, the team will inherit the default recipients from the organisation. You can override these defaults by adding the recipients you want to be added to every document you send.
![A screenshot of the team's default recipients page](/default-recipients/team-default-recipients.webp)
@@ -0,0 +1,179 @@
---
title: PDF Placeholders
description: Learn how to use placeholder text in your PDFs for automatic field placement in Documenso.
---
import { Callout } from 'nextra/components';
# PDF Placeholders
Documenso can automatically detect placeholder text in your PDF documents and create fields at those locations. This allows you to prepare documents in your preferred editing tool (Word, Google Docs, etc.) with placeholders that become signature fields when uploaded.
## How It Works
When you upload a PDF, Documenso scans for text matching the placeholder pattern `{{...}}`. Each placeholder can specify:
1. **Field type** - What kind of field to create (signature, name, email, etc.)
2. **Recipient** - Which signer the field belongs to (r1, r2, etc.)
3. **Options** - Additional settings like required, read-only, font size, etc.
The placeholder text is automatically hidden after fields are created, so your final document looks clean.
## Placeholder Format
The basic format is:
```
{{FIELD_TYPE, RECIPIENT, option1=value1, option2=value2}}
```
### Examples
| Placeholder | Description |
| ----------------------------- | ----------------------------------- |
| `{{signature, r1}}` | Signature field for recipient 1 |
| `{{name, r1}}` | Name field for recipient 1 |
| `{{email, r2}}` | Email field for recipient 2 |
| `{{date, r1}}` | Date field for recipient 1 |
| `{{text, r1, required=true}}` | Required text field for recipient 1 |
| `{{initials, r1}}` | Initials field for recipient 1 |
## Supported Field Types
The following field types are supported in placeholders:
| Field Type | Placeholder Value |
| ---------- | ----------------- |
| Signature | `signature` |
| Initials | `initials` |
| Name | `name` |
| Email | `email` |
| Date | `date` |
| Text | `text` |
| Number | `number` |
| Radio | `radio` |
| Checkbox | `checkbox` |
| Dropdown | `dropdown` |
<Callout type="info">
Field types are case-insensitive. `{{ SIGNATURE, r1 }}` and `{{ signature, r1 }}` are equivalent.
</Callout>
## Recipient Identifiers
Recipients are identified using `r1`, `r2`, `r3`, etc. The number corresponds to the order in which recipients are created:
- `r1` - First recipient
- `r2` - Second recipient
- `r3` - Third recipient
When you upload a PDF with placeholders, Documenso will:
1. Create placeholder recipients for each unique identifier found (e.g., `r1`, `r2`)
2. You can then update these with real email addresses before sending
<Callout type="warning">
Placeholders without a recipient identifier (e.g., `{{ signature }}` without `r1`) are reserved
for API use and will not create fields during upload.
</Callout>
## Field Options
You can customize fields by adding options after the recipient identifier:
### Common Options
| Option | Values | Description |
| ----------- | ------------------------- | ------------------------------------------ |
| `required` | `true`, `false` | Whether the field must be filled |
| `readOnly` | `true`, `false` | Whether the field is pre-filled and locked |
| `fontSize` | Number (e.g., `12`) | Font size in points |
| `textAlign` | `left`, `center`, `right` | Horizontal text alignment |
### Text Field Options
| Option | Values | Description |
| ---------------- | ------ | ------------------------------------- |
| `label` | Text | Label shown in the field |
| `placeholder` | Text | Placeholder text shown before signing |
| `text` | Text | Pre-filled text value |
| `characterLimit` | Number | Maximum characters allowed |
### Number Field Options
| Option | Values | Description |
| -------------- | ------------- | --------------------- |
| `value` | Number | Pre-filled value |
| `minValue` | Number | Minimum allowed value |
| `maxValue` | Number | Maximum allowed value |
| `numberFormat` | Format string | Number display format |
### Examples with Options
```
{{text, r1, required=true, label=Company Name}}
{{number, r1, minValue=0, maxValue=100, value=50}}
{{name, r1, fontSize=14}}
{{text, r2, readOnly=true, text=Contract #12345}}
```
<Callout type="info">
Signature and Free Signature fields do not support additional options beyond the field type and
recipient.
</Callout>
## Multiple Recipients Example
Here's how a document might look with placeholders for two signers:
```
AGREEMENT
Party A Signature: {{signature, r1}}
Party A Name: {{name, r1}}
Party A Date: {{date, r1}}
Party B Signature: {{signature, r2}}
Party B Name: {{name, r2}}
Party B Date: {{date, r2}}
```
When uploaded, this creates:
- 3 fields assigned to recipient 1 (Party A)
- 3 fields assigned to recipient 2 (Party B)
- 2 placeholder recipients that you can update with real email addresses
## Tips for Creating Documents
1. **Use a readable font** - Placeholders need to be readable by the PDF parser. Standard fonts like Arial, Helvetica, or Times New Roman work best.
2. **Don't split placeholders** - Ensure the entire placeholder text `{{...}}` is on a single line and not broken across text boxes.
3. **Size matters** - The field will be sized to match the placeholder text width. Use spaces or longer placeholder text if you need wider fields.
4. **Test with a draft** - Upload your document as a draft first to verify fields are detected correctly before sending.
<Callout type="info">
Placeholder detection happens automatically when you upload a PDF. You can review and adjust the
created fields in the document editor before sending.
</Callout>
## Troubleshooting
### Placeholders Not Detected
- Ensure placeholders use double curly braces: `{{...}}`
- Check that the placeholder includes a recipient identifier (e.g., `r1`)
- Verify the field type is spelled correctly
- Try using a standard font in your source document
### Wrong Field Position
- The field is placed at the exact location of the placeholder text
- If the position seems off, check that your PDF wasn't scaled or reformatted when exported
### Placeholder Text Still Visible
- Placeholder text is covered with a white rectangle after field creation
- If you see the text, try re-uploading the document
@@ -1,3 +1,8 @@
---
title: Email Domains
description: Learn how to create and manage email domains in Documenso.
---
import { Callout, Steps } from 'nextra/components';
# Email Domains
+25 -12
View File
@@ -7,28 +7,41 @@ import { Callout } from 'nextra/components';
# Fair Use Policy
### Why
We like to overdeliver, but we cannot overcommit.
We offer our plans without any limits on volume because we want our users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using. This is why the individual plan and the team plan do not include a limit on signing or API volume. If you are a customer of these [plans](https://documen.so/pricing), we ask you to abide by this fair use policy:
Our plans are designed to be generous and flexible without forcing customers into rigid volume limits they may never use. At the same time, estimating usage at scale is hard, especially over short periods. This fair use policy exists to keep plans sustainable while allowing us to add more value wherever possible without overformalizing restrictions.
We offer our plans without any limits on volume because we want users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using.
This is why our plans not include a limit on signing or API volume. If you are a customer of these plans, we ask you to abide by this fair use policy.
### Spirit of the Plan
> Use the limitless accounts as much as you like (they are meant to offer a lot) while respecting the spirit and intended scope of the account.
Use the limitless plans as much as you like. They are meant to offer a lot. Please respect the spirit and intended scope of the account.
<Callout type="info">
What happens if I violate this policy? We will ask you to upgrade to a fitting plan or custom
pricing. We wont block your account without reaching out. [Message
us](mailto:support@documenso.com) for questions. It's probably fine, though.
What happens if I go beyond the scope of this policy? We will ask you to upgrade to a fitting plan
or custom pricing. We will not block your account without reaching out. You can message us for
questions.
</Callout>
### Fair Support
We believe in fair support as much as fair usage.
Fair support includes reasonable and within reason application level help for self hosted users. We will help you get unstuck and point you in the right direction when issues come up. Support is provided in good faith and within reasonable time and effort limits. We are not your operations team and cannot take responsibility for running, monitoring, or maintaining your infrastructure.
If you are unsure whether something falls within fair use or fair support, reach out. We are happy to talk it through.
### DO
- Sign as many documents with the individual plan for your single business or organization you are part of
- Use the API and Zapier to automate all your signing to sign as much as possible
- Experiment with the plans and integrations, testing what you want to build: When in doubt, do it. Especially if you are just starting.
- Sign as many documents as you need with the individual plan for your single business or organization
- Use the API and automation tools to automate your signing workflows
- Experiment with plans and integrations while testing what you want to build
### DON'T
- Use the individual account's API to power a platform
- Run a huge company, signing thousands of documents per day on a two-user team plan using the API
- Let this policy make you overthink. If you are a paying customer, we want you to win, and it's probably fine
- Use an individual account API to power a platform or product
- Run a large company signing thousands of documents per day on a small team plan
- Expect enterprise level support for fair support plan
- Overthink this policy. If you are a paying customer, we want you to win
@@ -10,7 +10,12 @@ import { Callout, Steps } from 'nextra/components';
<Steps>
### Pick a Plan
The first step to start using Documenso is to pick a plan and create an account. At the moment of writing this guide, we have 3 plans available: Free, Individual, Teams and Platform.
The first step to start using Documenso is to pick a plan and create an account. At the moment of writing this guide, we have 4 plans available:
- Free
- Individual
- Teams
- Platform
Explore each plan's features and choose the one that best suits your needs. The [pricing page](https://documen.so/pricing) has more information about the plans.
@@ -24,7 +29,7 @@ To create a free account, navigate to the [registration page](https://documen.so
### Optional: Claim a Premium Username
You can claim a premium username by upgrading to a paid plan. After upgrading to a paid plan, you can update your [public profile](https://app.documenso.com/settings/public-profile).
You can claim a premium username by upgrading to a paid plan. After upgrading to a paid plan, you can update your [public profile](/users/profile).
### Optional: Create a Team
@@ -1,3 +1,8 @@
---
title: Community Edition
description: Learn about the Community Edition of Documenso.
---
import { Callout } from 'nextra/components';
# Community Edition
@@ -32,10 +37,10 @@ Documenso and the Community Edition are licensed under [AGPL3](https://github.co
### Conditions
License and copyright notice
State changes
Disclose source
Network use is distribution
- License and copyright notice
- State changes
- Disclose source
- Network use is distribution
<Callout type="warning">
It's important to remember that you must keep the AGPL3 license for your modified or non-modified
@@ -1,21 +1,57 @@
---
title: Enterprise Edition
description: Learn about the Enterprise Edition of Documenso.
---
import { Callout } from 'nextra/components';
# Enterprise Edition
The Documenso Enterprise Edition is our license for self-hosters that need the full range of support and compliance. Everything in the EE folder and all features listed [here](https://github.com/documenso/documenso/blob/main/packages/ee/FEATURES) can be used after acquiring a paid license.
## Includes
- Self-Host Documenso in any context.
- Premium Support via Slack, Discord and Email.
- Flexible Licensing (e.g. MIT) for deeper custom integration (if needed).
- Access to all Enterprise-grade compliance and administration features.
## Limitations
The Enterprise Edition currently has no limitations except custom contract terms.
<Callout type="info">
The Enterprise Edition requires a paid subscription. [Contact us for a
quote](https://documen.so/enterprise).
</Callout>
The Documenso Enterprise Edition is our license for self-hosters that need the full range of support and compliance.
The following features are included in the Enterprise Edition:
{/* Keep this synced with the packages/ee/FEATURES file */}
- The Stripe Billing Module
- Organisation Authentication Portal
- Document Action Reauthentication (Passkeys and 2FA)
- 21 CFR
- Email domains
- Embed authoring
- Embed authoring white label
In addition, you will receive:
- Premium Support via Slack, Discord and Email.
- Flexible Licensing (e.g. MIT) for deeper custom integration (if needed).
- Access to Enterprise-grade compliance and administration features.
- Permission to self-Host Documenso in any context.
The Enterprise Edition currently has no limitations except custom contract terms.
## Getting a License
To acquire an Enterprise Edition license, please [contact our sales team](https://documen.so/enterprise) for a quote. Our team will work with you to understand your requirements and provide a license that fits your needs.
## Using Your License
Once you have acquired an Enterprise Edition license:
1. Access your license key at [license.documenso.com](https://license.documenso.com)
2. Set the `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` environment variable in your Documenso instance with your license key
```bash
NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY="your-license-key-here"
```
3. You can verify your license status in the Admin Panel under the Stats section.
![Admin License Status](/images/admin-license-status.webp)
Your license will be verified on startup and periodically to ensure continued access to Enterprise features.
@@ -1,3 +1,8 @@
---
title: Licenses
description: Learn about the different licenses for self-hosting Documenso.
---
# Self-Hosting Licenses
Documenso comes in two versions for self-hosting:
+1 -1
View File
@@ -15,7 +15,7 @@ Documenso allows you to create a public profile to share your templates for anyo
### Navigate to Your Profile Settings
Click on your profile picture in the top right corner and select "Settings" or "Team Settings". Then, navigate to the "Public Profile" tab to configure your profile.
Click on your profile picture in the top right corner and select "Team Settings". Then, navigate to the "Public Profile" tab to configure your profile.
![The profile settings page](/public-profile/documenso-public-profile-settings.webp)
+6 -6
View File
@@ -9,30 +9,30 @@ description: Learn what types of support we offer.
If you are a developer or free user, you can reach out to the community or raise an issue:
### [Create Github Issues](https://github.com/documenso/documenso/issues)
**[Create Github Issues](https://github.com/documenso/documenso/issues)**
The community and the core team address GitHub issues. Be sure to check if a similar issue already exists. Please note that while we want to address everything immediately, we must prioritize.
### [Join our Discord](https://documen.so/discord)
**[Join our Discord](https://documen.so/discord)**
You can ask for help in the [community help channel](https://discord.com/channels/1132216843537485854/1133419426524430376).
## Paid Account Support
### Email: support@documenso.com
**Email: support@documenso.com**
If you are paying customers facing issues, email our customer support, especially in urgent cases.
### Private Discord channel
**Private Discord channel**
If you prefer Discord, we can invite you to a private channel. Message support to make this happen.
## Enterprise Support
### Email: support@documenso.com
**Email: support@documenso.com**
If you are paying customers facing issues, email our customer support, especially in urgent cases.
### Slack
**Slack**
If your team is on Slack, we can create a private workspace to support you more closely.
@@ -1,3 +1,8 @@
---
title: Templates
description: Learn how to create and use templates in Documenso.
---
import { Callout, Steps } from 'nextra/components';
# Document Templates
Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

@@ -3,6 +3,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { z } from 'zod';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
import { trpc } from '@documenso/trpc/react';
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
@@ -22,7 +23,11 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
export const ClaimCreateDialog = () => {
type ClaimCreateDialogProps = {
licenseFlags?: TLicenseClaim;
};
export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -67,6 +72,7 @@ export const ClaimCreateDialog = () => {
...generateDefaultSubscriptionClaim(),
}}
onFormSubmit={createClaim}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { trpc } from '@documenso/trpc/react';
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
import { Button } from '@documenso/ui/primitives/button';
@@ -21,9 +22,10 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
export type ClaimUpdateDialogProps = {
claim: TFindSubscriptionClaimsResponse['data'][number];
trigger: React.ReactNode;
licenseFlags?: TLicenseClaim;
};
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -69,6 +71,7 @@ export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) =>
data,
})
}
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button
@@ -3,13 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import {
DocumentDistributionMethod,
DocumentStatus,
EnvelopeType,
FieldType,
RecipientRole,
} from '@prisma/client';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
@@ -20,6 +14,7 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
@@ -140,14 +135,7 @@ export const EnvelopeDistributeDialog = ({
);
const recipientsMissingSignatureFields = useMemo(
() =>
recipientsWithIndex.filter(
(recipient) =>
recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
() => getRecipientsWithMissingFields(recipientsWithIndex, envelope.fields),
[recipientsWithIndex, envelope.fields],
);
@@ -0,0 +1,160 @@
import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopesBulkDeleteDialogProps = {
envelopeIds: string[];
envelopeType: EnvelopeType;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopesBulkDeleteDialog = ({
envelopeIds,
envelopeType,
open,
onOpenChange,
onSuccess,
...props
}: EnvelopesBulkDeleteDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const trpcUtils = trpc.useUtils();
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const { mutateAsync: bulkDeleteEnvelopes, isPending } = trpc.envelope.bulk.delete.useMutation({
onSuccess: async (result) => {
// Invalidate the appropriate query based on envelope type.
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
}
if (result.failedIds.length > 0) {
toast({
title: isDocument ? t`Documents partially deleted` : t`Templates partially deleted`,
description: t`${result.deletedCount} item(s) deleted. ${result.failedIds.length} item(s) could not be deleted.`,
variant: 'destructive',
});
} else {
toast({
title: isDocument ? t`Documents deleted` : t`Templates deleted`,
description: t`${result.deletedCount} item(s) have been deleted.`,
variant: 'default',
});
}
onSuccess?.();
onOpenChange(false);
},
onError: () => {
toast({
title: t`Error`,
description: t`An error occurred while deleting the items.`,
variant: 'destructive',
});
},
});
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isDocument ? <Trans>Delete Documents</Trans> : <Trans>Delete Templates</Trans>}
</DialogTitle>
<DialogDescription>
{isDocument ? (
<Plural
value={envelopeIds.length}
one="You are about to delete the selected document."
other="You are about to delete # documents."
/>
) : (
<Plural
value={envelopeIds.length}
one="You are about to delete the selected template."
other="You are about to delete # templates."
/>
)}
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>
Please note that this action is <strong>irreversible</strong>.
</Trans>
</p>
<p className="mt-1">
<Trans>Once confirmed, the following will occur:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
{isDocument ? (
<>
<li>
<Trans>Selected documents will be permanently deleted</Trans>
</li>
<li>
<Trans>Pending documents will have their signing process cancelled</Trans>
</li>
<li>
<Trans>All recipients will be notified</Trans>
</li>
</>
) : (
<>
<li>
<Trans>Selected templates will be permanently deleted</Trans>
</li>
<li>
<Trans>Direct links associated with templates will be removed</Trans>
</li>
</>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={(e) => {
e.preventDefault();
void bulkDeleteEnvelopes({ envelopeIds });
}}
loading={isPending}
variant="destructive"
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,256 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopesBulkMoveDialogProps = {
envelopeIds: string[];
envelopeType: EnvelopeType;
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
onSuccess?: () => void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZBulkMoveFormSchema = z.object({
folderId: z.string().nullable(),
});
type TBulkMoveFormSchema = z.infer<typeof ZBulkMoveFormSchema>;
export const EnvelopesBulkMoveDialog = ({
envelopeIds,
envelopeType,
open,
onOpenChange,
currentFolderId,
onSuccess,
...props
}: EnvelopesBulkMoveDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TBulkMoveFormSchema>({
resolver: zodResolver(ZBulkMoveFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId,
type: envelopeType,
},
{
enabled: open,
},
);
const { mutateAsync: bulkMoveEnvelopes } = trpc.envelope.bulk.move.useMutation();
const trpcUtils = trpc.useUtils();
useEffect(() => {
if (open) {
setSearchTerm('');
form.reset({
folderId: currentFolderId,
});
}
}, [open, currentFolderId]);
const onSubmit = async (data: TBulkMoveFormSchema) => {
try {
await bulkMoveEnvelopes({
envelopeIds,
folderId: data.folderId,
envelopeType,
});
// Invalidate the appropriate query based on envelope type.
if (isDocument) {
await trpcUtils.document.findDocumentsInternal.invalidate();
} else {
await trpcUtils.template.findTemplates.invalidate();
}
toast({
description: t`Selected items have been moved.`,
});
onSuccess?.();
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_FOUND,
() => t`The folder you are trying to move the items to does not exist.`,
)
.with(AppErrorCode.UNAUTHORIZED, () => t`You are not allowed to move these items.`)
.with(AppErrorCode.INVALID_BODY, () => t`All items must be of the same type.`)
.otherwise(() => t`An error occurred while moving the items.`);
toast({
description: errorMessage,
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isDocument ? (
<Trans>Move Documents to Folder</Trans>
) : (
<Trans>Move Templates to Folder</Trans>
)}
</DialogTitle>
<DialogDescription>
{isDocument ? (
<Plural
value={envelopeIds.length}
one="Select a folder to move the selected document to."
other="Select a folder to move the # selected documents to."
/>
) : (
<Plural
value={envelopeIds.length}
one="Select a folder to move the selected template to."
other="Select a folder to move the # selected templates to."
/>
)}
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t`Search folders...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === undefined}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-sm text-muted-foreground">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={isFoldersLoading || form.formState.isSubmitting}
loading={form.formState.isSubmitting}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -73,7 +73,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
try {
const passkeyRegistrationOptions = await createPasskeyRegistrationOptions();
const registrationResult = await startRegistration(passkeyRegistrationOptions);
const registrationResult = await startRegistration({
optionsJSON: passkeyRegistrationOptions,
});
await createPasskey({
passkeyName,
@@ -82,9 +82,7 @@ export const SignFieldCheckboxDialog = createCallable<
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Sign Checkbox Field</Trans>
</DialogTitle>
<DialogTitle>{fieldMeta.label || <Trans>Select Options</Trans>}</DialogTitle>
<DialogDescription
className={cn('mt-4', {
@@ -143,7 +141,7 @@ export const SignFieldCheckboxDialog = createCallable<
<div className="flex items-center">
<Checkbox
id={`checkbox-value-${index}`}
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
checked={field.value.checked}
onCheckedChange={(checked) => {
field.onChange({
@@ -154,7 +152,7 @@ export const SignFieldCheckboxDialog = createCallable<
/>
<label
className="text-muted-foreground ml-2 w-full text-sm"
className="ml-2 w-full text-sm text-muted-foreground"
htmlFor={`checkbox-value-${index}`}
>
{value.value}
@@ -174,7 +172,7 @@ export const SignFieldCheckboxDialog = createCallable<
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Confirm</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -50,11 +50,11 @@ export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, st
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Email</Trans>
<Trans>Enter Email</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Sign your email into the field</Trans>
<Trans>Please enter your email address</Trans>
</DialogDescription>
</DialogHeader>
@@ -83,7 +83,7 @@ export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, st
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -48,11 +48,11 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Initials</Trans>
<Trans>Enter Initials</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Sign your initials into the field</Trans>
<Trans>Please enter your initials</Trans>
</DialogDescription>
</DialogHeader>
@@ -84,7 +84,7 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -47,11 +47,11 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Name</Trans>
<Trans>Enter Name</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Sign your full name into the field</Trans>
<Trans>Please enter your full name</Trans>
</DialogDescription>
</DialogHeader>
@@ -80,7 +80,7 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -22,7 +22,6 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
@@ -107,12 +106,10 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Number Field</Trans>
</DialogTitle>
<DialogTitle>{fieldMeta.label || <Trans>Enter Number</Trans>}</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Insert a value into the number field</Trans>
<Trans>Please enter a number</Trans>
</DialogDescription>
</DialogHeader>
@@ -127,8 +124,6 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
name="number"
render={({ field, fieldState }) => (
<FormItem>
{fieldMeta.label && <FormLabel>{fieldMeta.label}</FormLabel>}
<FormControl>
<Input
placeholder={fieldMeta.placeholder ?? t`Enter your number here`}
@@ -150,7 +145,7 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -22,7 +22,6 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Textarea } from '@documenso/ui/primitives/textarea';
@@ -52,12 +51,10 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Sign Text Field</Trans>
</DialogTitle>
<DialogTitle>{fieldMeta?.label || <Trans>Enter Text</Trans>}</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Insert a value into the text field</Trans>
<Trans>Please enter a value</Trans>
</DialogDescription>
</DialogHeader>
@@ -72,8 +69,6 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
name="text"
render={({ field, fieldState }) => (
<FormItem>
{fieldMeta?.label && <FormLabel>{fieldMeta?.label}</FormLabel>}
<FormControl>
<Textarea
id="custom-text"
@@ -89,7 +84,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
{fieldMeta?.characterLimit !== undefined &&
fieldMeta?.characterLimit > 0 &&
!fieldState.error && (
<div className="text-muted-foreground text-sm">
<div className="text-sm text-muted-foreground">
<Plural
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
one="# character remaining"
@@ -107,7 +102,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
</Button>
<Button type="submit">
<Trans>Sign</Trans>
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon } from 'lucide-react';
import { InfoIcon, UserPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@@ -13,6 +13,7 @@ import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -44,6 +45,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberCreateDialogProps = {
@@ -64,11 +66,14 @@ type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const prevInviteDialogOpenRef = useRef(false);
const { t } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const utils = trpc.useUtils();
const form = useForm<TAddTeamMembersFormSchema>({
resolver: zodResolver(ZAddTeamMembersFormSchema),
@@ -96,7 +101,29 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
);
}, [organisationMemberQuery, teamMemberQuery]);
const hasNoAvailableMembers =
!organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0;
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
if (members.length === 0) {
if (hasNoAvailableMembers) {
setInviteDialogOpen(true);
return;
}
// Don't show error if on SELECT step - the disabled Next button already communicates this
if (step === 'SELECT') {
return;
}
toast({
title: t`No members selected`,
description: t`Please select at least one member to add to the team.`,
variant: 'destructive',
});
return;
}
try {
await createTeamMembers({
teamId: team.id,
@@ -123,9 +150,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
if (!open) {
form.reset();
setStep('SELECT');
setInviteDialogOpen(false);
}
}, [open, form]);
// Invalidate queries when invite dialog closes (transitions from true to false) to refresh available members
useEffect(() => {
if (prevInviteDialogOpenRef.current && !inviteDialogOpen) {
void utils.organisation.member.find.invalidate({
organisationId: team.organisationId,
});
}
prevInviteDialogOpenRef.current = inviteDialogOpen;
}, [inviteDialogOpen, utils, team.organisationId]);
return (
<Dialog
{...props}
@@ -134,9 +172,11 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
// Since it would be annoying to redo the whole process.
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add members</Trans>
</Button>
{trigger ?? (
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add members</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent hideClose={true} position="center">
@@ -149,7 +189,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<TooltipTrigger asChild>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-xs">
<TooltipContent className="z-[99999] max-w-xs text-muted-foreground">
<Trans>
To be able to add members to a team, you must first add them to the
organisation. For more information, please see the{' '}
@@ -186,7 +226,18 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
.exhaustive()}
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<form
onSubmit={form.handleSubmit(onFormSubmit)}
onKeyDown={(e) => {
if (e.key === 'Enter' && form.getValues('members').length === 0) {
e.preventDefault();
if (hasNoAvailableMembers) {
setInviteDialogOpen(true);
}
// Don't show toast - the disabled Next button already communicates this
}
}}
>
<fieldset disabled={form.formState.isSubmitting}>
{step === 'SELECT' && (
<>
@@ -194,46 +245,102 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
control={form.control}
name="members"
render={({ field }) => (
<FormItem>
<FormItem className="space-y-2">
<FormLabel>
<Trans>Members</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select members`}
/>
{hasNoAvailableMembers ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/25 bg-muted/30 px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<UserPlusIcon className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mb-2 text-sm font-semibold">
<Trans>No organisation members available</Trans>
</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
<Trans>
To add members to this team, you must first add them to the
organisation.
</Trans>
</p>
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button type="button" variant="default">
<UserPlusIcon className="mr-2 h-4 w-4" />
<Trans>Invite organisation members</Trans>
</Button>
}
/>
</div>
) : (
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="w-full bg-background"
emptySelectionPlaceholder={t`Select members`}
/>
)}
</FormControl>
<FormDescription>
<Trans>Select members to add to this team</Trans>
</FormDescription>
{!hasNoAvailableMembers && (
<>
<FormDescription>
<Trans>Select members to add to this team</Trans>
</FormDescription>
<Alert
variant="neutral"
className="mt-2 flex items-center gap-2 space-y-0"
>
<div>
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
</div>
<AlertDescription className="mt-0 flex-1">
<Trans>Can't find someone?</Trans>{' '}
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button
type="button"
variant="link"
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
>
<Trans>Invite them to the organisation first</Trans>
</Button>
}
/>
</AlertDescription>
</Alert>
</>
)}
</FormItem>
)}
/>
<DialogFooter>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
@@ -137,12 +137,12 @@ export const TemplateBulkSendDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<div className="bg-muted/70 rounded-lg border p-4">
<div className="rounded-lg border bg-muted/70 p-4">
<h3 className="text-sm font-medium">
<Trans>CSV Structure</Trans>
</h3>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-sm text-muted-foreground">
<Trans>
For each recipient, provide their email (required) and name (optional) in separate
columns. Download the template CSV below for the correct format.
@@ -153,7 +153,7 @@ export const TemplateBulkSendDialog = ({
<Trans>Current recipients:</Trans>
</p>
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
<ul className="mt-2 list-inside list-disc text-sm text-muted-foreground">
{recipients.map((recipient, index) => (
<li key={index}>
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
@@ -167,7 +167,7 @@ export const TemplateBulkSendDialog = ({
<Trans>Download Template CSV</Trans>
</Button>
<p className="text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground">
<Trans>Pre-formatted CSV template with example data.</Trans>
</p>
</div>
@@ -200,14 +200,14 @@ export const TemplateBulkSendDialog = ({
) : (
<div className="flex h-10 items-center rounded-md border px-3">
<div className="flex flex-1 items-center gap-2">
<FileIcon className="text-muted-foreground h-4 w-4" />
<FileIcon className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 truncate text-sm">{value.name}</span>
</div>
<Button
type="button"
variant="link"
className="text-destructive hover:text-destructive p-0 text-xs"
className="p-0 text-xs text-destructive hover:text-destructive"
onClick={() => onChange(null)}
disabled={form.formState.isSubmitting}
>
@@ -220,9 +220,9 @@ export const TemplateBulkSendDialog = ({
)}
</FormControl>
{error && <p className="text-destructive text-sm">{error.message}</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
<p className="text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground">
<Trans>
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
template defaults.
@@ -247,7 +247,7 @@ export const TemplateBulkSendDialog = ({
<label
htmlFor="send-immediately"
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
>
<Trans>Send documents to recipients immediately</Trans>
</label>
@@ -54,6 +54,8 @@ type TemplateDirectLinkDialogProps = {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[];
trigger?: React.ReactNode;
onCreateSuccess?: () => Promise<void> | void;
onDeleteSuccess?: () => Promise<void> | void;
};
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
@@ -63,6 +65,8 @@ export const TemplateDirectLinkDialog = ({
directLink,
recipients,
trigger,
onCreateSuccess,
onDeleteSuccess,
}: TemplateDirectLinkDialogProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
@@ -97,6 +101,7 @@ export const TemplateDirectLinkDialog = ({
} = trpcReact.template.createTemplateDirectLink.useMutation({
onSuccess: async (data) => {
await revalidate();
await onCreateSuccess?.();
setToken(data.token);
setIsEnabled(data.enabled);
@@ -142,6 +147,7 @@ export const TemplateDirectLinkDialog = ({
trpcReact.template.deleteTemplateDirectLink.useMutation({
onSuccess: async () => {
await revalidate();
await onDeleteSuccess?.();
setOpen(false);
setToken(null);
@@ -234,7 +240,7 @@ export const TemplateDirectLinkDialog = ({
</div>
<h3 className="font-semibold">{_(step.title)}</h3>
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
<p className="mt-1 text-sm text-muted-foreground">{_(step.description)}</p>
</li>
))}
</ul>
@@ -320,13 +326,13 @@ export const TemplateDirectLinkDialog = ({
onClick={async () => onRecipientTableRowClick(row.id)}
>
<TableCell>
<div className="text-muted-foreground text-sm">
<div className="text-sm text-muted-foreground">
<p>{row.name}</p>
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
<p className="text-xs text-muted-foreground/70">{row.email}</p>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
<TableCell className="text-sm text-muted-foreground">
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
</TableCell>
@@ -350,7 +356,7 @@ export const TemplateDirectLinkDialog = ({
<DialogFooter className="mx-auto">
<div className="flex flex-col items-center justify-center">
{validDirectTemplateRecipients.length !== 0 && (
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
<Trans>Or</Trans>
</p>
)}
@@ -392,7 +398,7 @@ export const TemplateDirectLinkDialog = ({
<TooltipTrigger tabIndex={-1} className="ml-2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<Trans>
Disabling direct link signing will prevent anyone from accessing the
link.
@@ -3,8 +3,14 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import {
type DocumentMeta,
type EnvelopeItem,
type Field,
FieldType,
type Recipient,
type Signature,
} from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
@@ -18,6 +24,7 @@ import {
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@@ -96,7 +103,7 @@ export const EmbedDirectTemplateClientPage = ({
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
@@ -4,13 +4,14 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import {
@@ -115,7 +116,7 @@ export const EmbedSignDocumentV1ClientPage = ({
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
@@ -3,12 +3,13 @@ import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
import { DocumentStatus, SigningStatus } from '@prisma/client';
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
@@ -83,7 +84,7 @@ export const MultiSignDocumentSigningView = ({
const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation();
const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
const [pendingFields, completedFields] = [
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
@@ -3,7 +3,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, OrganisationType } from '@prisma/client';
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -17,14 +17,19 @@ import {
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import {
type TDocumentMetaDateFormat,
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
@@ -45,6 +50,10 @@ import {
SelectValue,
} from '@documenso/ui/primitives/select';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
/**
* Can't infer this from the schema since we need to keep the schema inside the component to allow
* it to be dynamic.
@@ -58,6 +67,8 @@ export type TDocumentPreferencesFormSchema = {
includeSigningCertificate: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
aiFeaturesEnabled: boolean | null;
};
@@ -73,6 +84,8 @@ type SettingsSubset = Pick<
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
>;
@@ -92,6 +105,7 @@ export const DocumentPreferencesForm = ({
const { t } = useLingui();
const { user, organisations } = useSession();
const currentOrganisation = useCurrentOrganisation();
const optionalTeam = useOptionalCurrentTeam();
const isPersonalLayoutMode = isPersonalLayout(organisations);
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
@@ -109,6 +123,8 @@ export const DocumentPreferencesForm = ({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
});
@@ -125,6 +141,10 @@ export const DocumentPreferencesForm = ({
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
@@ -515,6 +535,140 @@ export const DocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="defaultRecipients"
render={({ field }) => {
const recipients = field.value ?? [];
return (
<FormItem className="flex-1">
<FormLabel>
<Trans>Default Recipients</Trans>
</FormLabel>
{canInherit && (
<Select
value={field.value === null ? '-1' : '0'}
onValueChange={(value) => field.onChange(value === '-1' ? null : [])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
<SelectItem value={'0'}>
<Trans>Override organisation settings</Trans>
</SelectItem>
</SelectContent>
</Select>
)}
{(field.value !== null || !canInherit) && (
<div className="space-y-4">
<DefaultRecipientsMultiSelectCombobox
listValues={recipients}
onChange={field.onChange}
organisationId={!canInherit ? currentOrganisation.id : undefined}
teamId={canInherit ? optionalTeam?.id : undefined}
/>
{recipients.map((recipient, index) => {
return (
<div
key={recipient.email}
className="flex items-center justify-between gap-3 rounded-lg border p-3"
>
<AvatarWithText
avatarFallback={recipientAbbreviation(recipient)}
primaryText={
<span className="text-sm font-medium">
{recipient.name || recipient.email}
</span>
}
secondaryText={
recipient.name ? (
<span className="text-xs text-muted-foreground">
{recipient.email}
</span>
) : undefined
}
className="flex-1"
/>
<div className="flex items-center gap-2">
<RecipientRoleSelect
value={recipient.role}
onValueChange={(role: RecipientRole) => {
field.onChange(
recipients.map((recipient, idx) =>
idx === index ? { ...recipient, role } : recipient,
),
);
}}
/>
</div>
</div>
);
})}
</div>
)}
<FormDescription>
<Trans>Recipients that will be automatically added to new documents.</Trans>
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="delegateDocumentOwnership"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Delegate Document Ownership</Trans>
</FormLabel>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
<FormDescription>
<Trans>
Enable team API tokens to delegate document ownership to another team member.
</Trans>
</FormDescription>
</FormItem>
)}
/>
{isAiFeaturesConfigured && (
<FormField
control={form.control}
+9 -9
View File
@@ -171,7 +171,7 @@ export const SignInForm = ({
const { options, sessionId } = await createPasskeySigninOptions();
const credential = await startAuthentication(options);
const credential = await startAuthentication({ optionsJSON: options });
await authClient.passkey.signIn({
credential: JSON.stringify(credential),
@@ -368,7 +368,7 @@ export const SignInForm = ({
<p className="mt-2 text-right">
<Link
to="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
className="text-sm text-muted-foreground duration-200 hover:opacity-70"
>
<Trans>Forgot your password?</Trans>
</Link>
@@ -390,11 +390,11 @@ export const SignInForm = ({
<>
{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">
<div className="h-px flex-1 bg-border" />
<span className="bg-transparent text-muted-foreground">
<Trans>Or continue with</Trans>
</span>
<div className="bg-border h-px flex-1" />
<div className="h-px flex-1 bg-border" />
</div>
)}
@@ -403,7 +403,7 @@ export const SignInForm = ({
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignInWithGoogleClick}
>
@@ -417,7 +417,7 @@ export const SignInForm = ({
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignInWithMicrosoftClick}
>
@@ -435,7 +435,7 @@ export const SignInForm = ({
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
className="border bg-background text-muted-foreground"
disabled={isSubmitting}
onClick={onSignInWithOIDCClick}
>
@@ -452,7 +452,7 @@ export const SignInForm = ({
variant="outline"
disabled={isSubmitting}
loading={isPasskeyLoading}
className="bg-background text-muted-foreground border"
className="border bg-background text-muted-foreground"
onClick={onSignInWithPasskey}
>
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
@@ -2,10 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { SubscriptionClaim } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import type { z } from 'zod';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
@@ -24,15 +27,22 @@ type SubscriptionClaimFormProps = {
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
formSubmitTrigger?: React.ReactNode;
licenseFlags?: TLicenseClaim;
};
export const SubscriptionClaimForm = ({
subscriptionClaim,
onFormSubmit,
formSubmitTrigger,
licenseFlags,
}: SubscriptionClaimFormProps) => {
const { t } = useLingui();
const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim],
);
const form = useForm<SubscriptionClaimFormValues>({
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
defaultValues: {
@@ -142,34 +152,59 @@ export const SubscriptionClaimForm = ({
</FormLabel>
<div className="mt-2 space-y-2 rounded-md border p-4">
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
<FormField
key={key}
control={form.control}
name={`flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
/>
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(
({ key, label, isEnterprise }) => {
const isRestrictedFeature =
isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
htmlFor={`flag-${key}`}
>
{label}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
))}
return (
<FormField
key={key}
control={form.control}
name={`flags.${key}`}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id={`flag-${key}`}
checked={field.value}
onCheckedChange={field.onChange}
disabled={isRestrictedFeature && !field.value} // Allow disabling of restricted features.
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={`flag-${key}`}
>
{label}
{isRestrictedFeature && ' ¹'}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
);
},
)}
</div>
{hasRestrictedEnterpriseFeatures && (
<Alert variant="neutral" className="mt-4">
<AlertDescription>
<span>¹&nbsp;</span>
<Trans>Your current license does not include these features.</Trans>{' '}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="text-foreground underline hover:opacity-80"
>
<Trans>Learn more</Trans>
</Link>
</AlertDescription>
</Alert>
)}
</div>
{formSubmitTrigger}
+4 -4
View File
@@ -212,12 +212,12 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
/>
<div>
<FormLabel className="text-muted-foreground mt-2">
<FormLabel className="mt-2 text-muted-foreground">
<Trans>Never expire</Trans>
</FormLabel>
<div className="block md:py-1.5">
<Switch
className="bg-background mt-2"
className="mt-2 bg-background"
checked={noExpirationDate}
onCheckedChange={setNoExpirationDate}
/>
@@ -254,14 +254,14 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
>
<Card gradient>
<CardContent className="p-4">
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-sm text-muted-foreground">
<Trans>
Your token was created successfully! Make sure to copy it because you won't be
able to see it again!
</Trans>
</p>
<p className="bg-muted-foreground/10 my-4 rounded-md px-2.5 py-1 font-mono text-sm">
<p className="my-4 rounded-md bg-muted-foreground/10 px-2.5 py-1 font-mono text-sm">
{newlyCreatedToken.token}
</p>
@@ -0,0 +1,212 @@
import { Trans, useLingui } from '@lingui/react/macro';
import {
ArrowRightIcon,
CheckCircle2Icon,
KeyRoundIcon,
Loader2Icon,
RefreshCwIcon,
XCircleIcon,
} from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
import { trpc } from '@documenso/trpc/react';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { CardMetric } from './metric-card';
type AdminLicenseCardProps = {
licenseData: TCachedLicense | null;
};
export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
const { t, i18n } = useLingui();
const { license } = licenseData || {};
if (!license) {
return (
<div className="relative">
<div className="absolute right-3 top-3 z-10">
<AdminLicenseResyncButton />
</div>
<CardMetric icon={KeyRoundIcon} title={t`License`} className="h-fit max-h-fit">
<div className="mt-1 flex items-center justify-center gap-2">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-dashed border-muted-foreground/30 bg-muted/50">
<KeyRoundIcon className="h-5 w-5 text-muted-foreground/50" />
</div>
<div className="flex flex-col gap-0.5">
{licenseData?.requestedLicenseKey ? (
<>
<p className="text-sm font-medium text-destructive">
<Trans>Invalid License Key</Trans>
</p>
<p className="text-xs text-muted-foreground">{licenseData.requestedLicenseKey}</p>
</>
) : (
<p className="text-sm font-medium text-muted-foreground">
<Trans>No License Configured</Trans>
</p>
)}
<Link
to="https://docs.documenso.com/users/licenses/enterprise-edition"
target="_blank"
className="flex flex-row items-center text-xs text-muted-foreground hover:text-muted-foreground/80"
>
<Trans>Learn more</Trans> <ArrowRightIcon className="h-3 w-3" />
</Link>
</div>
</div>
</CardMetric>
</div>
);
}
const enabledFlags = Object.entries(license.flags).filter(([, enabled]) => enabled);
return (
<div className="relative max-w-full overflow-hidden rounded-lg border border-border bg-background px-4 pb-6 pt-4 shadow shadow-transparent duration-200 hover:shadow-border/80">
<div className="absolute right-3 top-3">
<AdminLicenseResyncButton />
</div>
<div className="flex items-start gap-2">
<div className="h-4 w-4">
<KeyRoundIcon className="h-4 w-4 text-muted-foreground" />
</div>
<h3 className="text-primary-forground mb-2 flex items-end text-sm font-medium leading-tight">
<Trans>Documenso License</Trans>
</h3>
{match(license.status)
.with('ACTIVE', () => (
<Badge variant="default" size="small">
<CheckCircle2Icon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Active</Trans>
</Badge>
))
.with('PAST_DUE', () => (
<Badge variant="warning" size="small">
<XCircleIcon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Past Due</Trans>
</Badge>
))
.with('EXPIRED', () => (
<Badge variant="destructive" size="small">
<XCircleIcon className="mr-1 h-3 w-3" />
<Trans context="Subscription status">Expired</Trans>
</Badge>
))
.otherwise(() => null)}
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-foreground">
<Trans>License</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{license.name}</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>Expires</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{i18n.date(license.periodEnd, DateTime.DATE_MED)}
</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>License Key</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{license.licenseKey}</p>
</div>
<div>
<p className="text-sm font-medium text-foreground">
<Trans>Features</Trans>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{enabledFlags.length > 0 ? (
enabledFlags
.map(
([flag]) =>
SUBSCRIPTION_CLAIM_FEATURE_FLAGS[
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
flag as keyof typeof SUBSCRIPTION_CLAIM_FEATURE_FLAGS
]?.label || flag,
)
.join(', ')
) : (
<Trans>No features enabled</Trans>
)}
</p>
</div>
</div>
</div>
);
};
const AdminLicenseResyncButton = () => {
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutate: resyncLicense, isPending: isResyncingLicense } =
trpc.admin.license.resync.useMutation({
onSuccess: async () => {
toast({
title: t`License synced`,
});
await revalidate();
},
onError: () => {
toast({
title: t`Failed to sync license`,
variant: 'destructive',
});
},
});
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
disabled={isResyncingLicense}
onClick={() => resyncLicense()}
>
{isResyncingLicense ? (
<Loader2Icon className="h-4 w-4 animate-spin" />
) : (
<RefreshCwIcon className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans>Sync license from server</Trans>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -0,0 +1,78 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon, KeyRoundIcon } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type AdminLicenseStatusBannerProps = {
license: TCachedLicense | null;
};
export const AdminLicenseStatusBanner = ({ license }: AdminLicenseStatusBannerProps) => {
const licenseStatus = license?.derivedStatus;
if (!license || licenseStatus === 'ACTIVE' || licenseStatus === 'NOT_FOUND') {
return null;
}
return (
<div
className={cn('mb-8 rounded-lg bg-yellow-200 text-yellow-900 dark:bg-yellow-400', {
'bg-destructive text-destructive-foreground':
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
})}
>
<div className="flex items-center justify-between gap-x-4 px-4 py-3 text-sm font-medium">
<div className="flex items-center">
<AlertTriangleIcon className="mr-2.5 h-5 w-5" />
{match(licenseStatus)
.with('PAST_DUE', () => (
<Trans>
License Payment Overdue - Please update your payment to avoid service disruptions.
</Trans>
))
.with('EXPIRED', () => (
<Trans>
License Expired - Please renew your license to continue using enterprise features.
</Trans>
))
.with('UNAUTHORIZED', () =>
license ? (
<Trans>
Invalid License Type - Your Documenso instance is using features that are not part
of your license.
</Trans>
) : (
<Trans>
Missing License - Your Documenso instance is using features that require a
license.
</Trans>
),
)
.otherwise(() => null)}
</div>
<Button
variant="outline"
size="sm"
className={cn({
'border-yellow-900/30 text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
licenseStatus === 'PAST_DUE',
'border-destructive-foreground/30 text-destructive-foreground hover:bg-destructive/80':
licenseStatus === 'EXPIRED' || licenseStatus === 'UNAUTHORIZED',
})}
asChild
>
<Link to="https://docs.documenso.com/users/licenses/enterprise-edition" target="_blank">
<KeyRoundIcon className="mr-1.5 h-4 w-4" />
<Trans>See Documentation</Trans>
</Link>
</Button>
</div>
</div>
);
};
@@ -0,0 +1,120 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans, useLingui as useLinguiMacro } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DefaultRecipientsMultiSelectComboboxProps = {
listValues: TDefaultRecipient[];
onChange: (_values: TDefaultRecipient[]) => void;
teamId?: number;
organisationId?: string;
};
export const DefaultRecipientsMultiSelectCombobox = ({
listValues,
onChange,
teamId,
organisationId,
}: DefaultRecipientsMultiSelectComboboxProps) => {
const { _ } = useLingui();
const { t } = useLinguiMacro();
const { toast } = useToast();
const { data: organisationData, isLoading: isLoadingOrganisation } =
trpc.organisation.member.find.useQuery(
{
organisationId: organisationId!,
query: '',
page: 1,
perPage: 100,
},
{
enabled: !!organisationId,
},
);
const { data: teamData, isLoading: isLoadingTeam } = trpc.team.member.find.useQuery(
{
teamId: teamId!,
query: '',
page: 1,
perPage: 100,
},
{
enabled: !!teamId,
},
);
const members = organisationId ? organisationData?.data : teamData?.data;
const isLoading = organisationId ? isLoadingOrganisation : isLoadingTeam;
const options = members?.map((member) => ({
value: member.email,
label: member.name ? `${member.name} (${member.email})` : member.email,
}));
const value = listValues.map((recipient) => ({
value: recipient.email,
label: recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email,
}));
const onSelectionChange = (selected: Option[]) => {
const invalidEmails = selected.filter(
(option) => !isRecipientEmailValidForSending({ email: option.value }),
);
if (invalidEmails.length > 0) {
toast({
title: t`Invalid email`,
description: t`"${invalidEmails[0].value}" is not a valid email address.`,
variant: 'destructive',
});
}
const updatedRecipients = selected
.filter((option) => !invalidEmails.includes(option))
.map((option) => {
const existingRecipient = listValues.find((r) => r.email === option.value);
const member = members?.find((m) => m.email === option.value);
return {
email: option.value,
name: member?.name || option.value,
role: existingRecipient?.role ?? RecipientRole.CC,
};
});
onChange(updatedRecipients);
};
return (
<MultiSelect
commandProps={{ label: _(msg`Select or add recipients`) }}
options={options}
value={value}
onChange={onSelectionChange}
placeholder={_(msg`Select or enter email address`)}
hideClearAllButton
hidePlaceholderWhenSelected
creatable
loadingIndicator={
isLoading ? (
<p className="text-center text-sm">
<Trans>Loading...</Trans>
</p>
) : undefined
}
emptyIndicator={
<p className="text-center text-sm">
<Trans>Type an email address to add a recipient</Trans>
</p>
}
/>
);
};
@@ -1,6 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import type { Field } from '@prisma/client';
@@ -57,8 +55,6 @@ export const DirectTemplateConfigureForm = ({
initialEmail,
onSubmit,
}: DirectTemplateConfigureFormProps) => {
const { _ } = useLingui();
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
@@ -77,17 +73,7 @@ export const DirectTemplateConfigureForm = ({
});
const form = useForm<TDirectTemplateConfigureFormSchema>({
resolver: zodResolver(
ZDirectTemplateConfigureFormSchema.superRefine((items, ctx) => {
if (template.recipients.map((recipient) => recipient.email).includes(items.email)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: _(msg`Email cannot already exist in the template`),
path: ['email'],
});
}
}),
),
resolver: zodResolver(ZDirectTemplateConfigureFormSchema),
defaultValues: {
email: initialEmail || '',
},
@@ -138,7 +124,7 @@ export const DirectTemplateConfigureForm = ({
</FormControl>
{!fieldState.error && (
<p className="text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground">
<Trans>Enter your email address to receive the completed document.</Trans>
</p>
)}
@@ -90,7 +90,7 @@ export const DocumentSigningAuthPasskey = ({
preferredPasskeyId: passkeyId,
});
const authenticationResponse = await startAuthentication(options);
const authenticationResponse = await startAuthentication({ optionsJSON: options });
await onReauthFormSubmit({
type: DocumentAuth.PASSKEY,
@@ -146,7 +146,7 @@ export const DocumentSigningAuthPasskey = ({
if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) {
return (
<div className="flex h-28 items-center justify-center">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
@@ -3,7 +3,7 @@ import { useId, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
import { type Field, type Recipient, RecipientRole } from '@prisma/client';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
@@ -11,6 +11,7 @@ import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
@@ -78,7 +79,7 @@ export const DocumentSigningForm = ({
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
@@ -35,7 +35,7 @@ export const DocumentSigningMobileWidget = () => {
return (
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
<div className="pointer-events-auto w-full max-w-[760px]">
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
<div className="overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
{/* Main Header Bar */}
<div className="flex items-center justify-between gap-4 p-4">
<div className="flex-1">
@@ -48,15 +48,15 @@ export const DocumentSigningMobileWidget = () => {
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? (
<LucideChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
<LucideChevronDown className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
) : (
<LucideChevronUp className="text-muted-foreground h-5 w-5 flex-shrink-0" />
<LucideChevronUp className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
)}
</Button>
)}
<div>
<h2 className="text-foreground text-lg font-semibold">
<h2 className="text-lg font-semibold text-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
@@ -65,7 +65,7 @@ export const DocumentSigningMobileWidget = () => {
.otherwise(() => null)}
</h2>
<p className="text-muted-foreground -mt-0.5 text-sm">
<p className="-mt-0.5 text-sm text-muted-foreground">
{recipientFieldsRemaining.length === 0 ? (
match(recipient.role)
.with(RecipientRole.VIEWER, () => (
@@ -102,11 +102,11 @@ export const DocumentSigningMobileWidget = () => {
{recipient.role !== RecipientRole.VIEWER &&
recipient.role !== RecipientRole.ASSISTANT && (
<div className="px-4 pb-3">
<div className="bg-muted relative h-[4px] rounded-md">
<div className="relative h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-signing-mobile-widget-progress-bar"
className="bg-primary absolute inset-y-0 left-0"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
@@ -117,11 +117,11 @@ export const DocumentSigningMobileWidget = () => {
{/* Expandable Content */}
{isExpanded && (
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
<div className="border-t border-border p-4 duration-200 animate-in slide-in-from-bottom-2">
<EnvelopeSignerForm />
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground mt-2 inline-block rounded px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:hidden">
<div className="mt-2 inline-block rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:hidden">
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
@@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DownloadIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { base64 } from '@documenso/lib/universal/base64';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -25,36 +27,15 @@ export const DocumentAuditLogDownloadButton = ({
const onDownloadAuditLogsClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId });
const { data, envelopeTitle } = await downloadAuditLogs({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
downloadFile({
data: blob,
filename: `${envelopeTitle} - Audit Logs.pdf`,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
@@ -4,6 +4,8 @@ import { Trans } from '@lingui/react/macro';
import type { DocumentStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { base64 } from '@documenso/lib/universal/base64';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -29,36 +31,15 @@ export const DocumentCertificateDownloadButton = ({
const onDownloadCertificatesClick = async () => {
try {
const { url } = await downloadCertificate({ documentId });
const { data, envelopeTitle } = await downloadCertificate({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
downloadFile({
data: blob,
filename: `${envelopeTitle} - Certificate.pdf`,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
@@ -22,7 +22,7 @@ export const DocumentPageViewRecentActivity = ({
documentId,
userId,
}: DocumentPageViewRecentActivityProps) => {
const { _ } = useLingui();
const { _, i18n } = useLingui();
const {
data,
@@ -48,9 +48,9 @@ export const DocumentPageViewRecentActivity = ({
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="text-foreground font-medium">
<h1 className="font-medium text-foreground">
<Trans>Recent activity</Trans>
</h1>
@@ -59,18 +59,18 @@ export const DocumentPageViewRecentActivity = ({
{isLoading && (
<div className="flex h-full items-center justify-center py-16">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-foreground/80 text-sm">
<p className="text-sm text-foreground/80">
<Trans>Unable to load document history</Trans>
</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
className="mt-2 text-sm text-foreground/70 hover:text-muted-foreground"
>
<Trans>Click here to retry</Trans>
</button>
@@ -83,16 +83,16 @@ export const DocumentPageViewRecentActivity = ({
{hasNextPage && (
<li className="relative flex gap-x-4">
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
<div className="bg-border w-px" />
<div className="w-px bg-border" />
</div>
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget">
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<button
onClick={async () => fetchNextPage()}
className="text-foreground/70 hover:text-muted-foreground text-xs"
className="text-xs text-foreground/70 hover:text-muted-foreground"
>
{isFetchingNextPage ? _(msg`Loading...`) : _(msg`Load older activity`)}
</button>
@@ -101,7 +101,7 @@ export const DocumentPageViewRecentActivity = ({
{documentAuditLogs.length === 0 && (
<div className="flex items-center justify-center py-4">
<p className="text-muted-foreground/70 text-sm">
<p className="text-sm text-muted-foreground/70">
<Trans>No recent activity</Trans>
</p>
</div>
@@ -115,44 +115,44 @@ export const DocumentPageViewRecentActivity = ({
'absolute left-0 top-0 flex w-6 justify-center',
)}
>
<div className="bg-border w-px" />
<div className="w-px bg-border" />
</div>
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget text-foreground/40">
{match(auditLog.type)
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<CheckIcon className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
</div>
))
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
<MailOpen className="h-3 w-3" aria-hidden="true" />
</div>
))
.otherwise(() => (
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
))}
</div>
<p
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
title={formatDocumentAuditLogAction(_, auditLog, userId).description}
className="flex-auto truncate py-0.5 text-xs leading-5 text-muted-foreground dark:text-muted-foreground/70"
title={formatDocumentAuditLogAction(i18n, auditLog, userId).description}
>
{formatDocumentAuditLogAction(_, auditLog, userId).description}
{formatDocumentAuditLogAction(i18n, auditLog, userId).description}
</p>
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
<time className="flex-none py-0.5 text-xs leading-5 text-muted-foreground dark:text-muted-foreground/70">
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
</time>
</li>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
DragDropContext,
@@ -7,28 +7,23 @@ import {
Droppable,
type SensorAPI,
} from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useFieldArray, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual, prop, sortBy } from 'remeda';
import { z } from 'zod';
import { isDeepEqual } from 'remeda';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
@@ -67,26 +62,9 @@ import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
const ZEnvelopeRecipientsForm = z.object({
signers: z.array(
z.object({
formId: z.string().min(1),
id: z.number().optional(),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
});
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients } =
useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
@@ -145,7 +123,6 @@ export const EnvelopeEditorRecipientForm = () => {
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null);
const isFirstRender = useRef(true);
const { recipients, fields } = envelope;
@@ -161,42 +138,7 @@ export const EnvelopeEditorRecipientForm = () => {
const recipientSuggestions = recipientSuggestionsData?.results || [];
const defaultRecipients = [
{
formId: initialId,
name: '',
email: '',
role: RecipientRole.SIGNER,
signingOrder: 1,
actionAuth: [],
},
];
const form = useForm<TEnvelopeRecipientsForm>({
resolver: zodResolver(ZEnvelopeRecipientsForm),
mode: 'onChange', // Used for autosave purposes, maybe can try onBlur instead?
defaultValues: {
signers:
recipients.length > 0
? sortBy(
recipients.map((recipient, index) => ({
id: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder ?? index + 1,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
})),
[prop('signingOrder'), 'asc'],
[prop('id'), 'asc'],
)
: defaultRecipients,
signingOrder: envelope.documentMeta.signingOrder,
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner,
},
});
const { form } = editorRecipients;
const recipientHasAuthSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => {
@@ -588,7 +530,7 @@ export const EnvelopeEditorRecipientForm = () => {
return;
}
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
const validatedFormValues = ZEditorRecipientsFormSchema.safeParse(formValues);
if (!validatedFormValues.success) {
return;
@@ -652,8 +594,12 @@ export const EnvelopeEditorRecipientForm = () => {
<Card backdropBlur={false} className="border">
<CardHeader className="flex flex-row justify-between">
<div>
<CardTitle>Recipients</CardTitle>
<CardDescription className="mt-1.5">Add recipients to your document</CardDescription>
<CardTitle>
<Trans>Recipients</Trans>
</CardTitle>
<CardDescription className="mt-1.5">
<Trans>Add recipients to your document</Trans>
</CardDescription>
</div>
<div className="flex flex-row items-center space-x-2">
@@ -848,246 +794,205 @@ export const EnvelopeEditorRecipientForm = () => {
ref={provided.innerRef}
className="flex w-full flex-col gap-y-2"
>
{signers.map((signer, index) => (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
})}
>
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4':
showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
{signers.map((signer, index) => {
const isDirectRecipient =
envelope.type === EnvelopeType.TEMPLATE &&
envelope.directLink !== null &&
signer.id === envelope.directLink.directTemplateRecipientId;
return (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
!isSigningOrderSequential ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
!signer.signingOrder
}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && (
<motion.fieldset
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4':
showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
{isSigningOrderSequential && (
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn(
'mt-auto flex items-center gap-x-1 space-y-0',
{
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.signingOrder`}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn(
'mt-auto flex items-center gap-x-1 space-y-0',
{
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<Input
type="number"
max={signers.length}
data-testid="signing-order-input"
className={cn(
'w-10 text-center',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
)}
{...field}
onChange={(e) => {
field.onChange(e);
handleSigningOrderChange(index, e.target.value);
}}
onBlur={(e) => {
field.onBlur();
handleSigningOrderChange(index, e.target.value);
}}
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={field.value}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
data-testid="signer-email-input"
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientRoleSelect
{...field}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
className={cn('w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
isDirectRecipient
}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
field.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
>
<FormControl>
<RecipientActionAuthSelect
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole);
field.onChange(value);
}}
disabled={
snapshot.isDragging ||
isSubmitting ||
@@ -1100,12 +1005,63 @@ export const EnvelopeEditorRecipientForm = () => {
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
))}
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id) ||
signers.length === 1 ||
isDirectRecipient
}
onClick={() => onRemoveSigner(index)}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
{showAdvancedSettings &&
organisation.organisationClaim.flags.cfr21 && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem
className={cn('mt-2 w-full', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.actionAuth,
'pl-6': isSigningOrderSequential,
})}
>
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={
snapshot.isDragging ||
isSubmitting ||
!canRecipientBeModified(signer.id)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</motion.fieldset>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
@@ -339,9 +339,11 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */}
<div className="bg-accent/20 flex w-80 flex-col border-r">
<div className="flex w-80 flex-col border-r bg-accent/20">
<DialogHeader className="p-6 pb-4">
<DialogTitle>Document Settings</DialogTitle>
<DialogTitle>
<Trans>Document Settings</Trans>
</DialogTitle>
</DialogHeader>
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
@@ -390,7 +392,7 @@ export const EnvelopeEditorSettingsDialog = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<Trans>
Controls the language for the document, including the language
to be used for email notifications, and the final certificate
@@ -441,7 +443,7 @@ export const EnvelopeEditorSettingsDialog = ({
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
className="w-full bg-background"
emptySelectionPlaceholder="Select signature types"
/>
</FormControl>
@@ -518,7 +520,7 @@ export const EnvelopeEditorSettingsDialog = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add an external ID to the document. This can be used to identify
the document in external systems.
@@ -548,7 +550,7 @@ export const EnvelopeEditorSettingsDialog = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
@@ -576,7 +578,7 @@ export const EnvelopeEditorSettingsDialog = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document Distribution Method</Trans>
@@ -735,14 +737,14 @@ export const EnvelopeEditorSettingsDialog = ({
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground p-4">
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea className="bg-background h-16 resize-none" {...field} />
<Textarea className="h-16 resize-none bg-background" {...field} />
</FormControl>
<FormMessage />
@@ -306,7 +306,7 @@ export const EnvelopeEditorUploadPage = () => {
ref={provided.innerRef}
{...provided.draggableProps}
style={provided.draggableProps.style}
className={`bg-accent/50 flex items-center justify-between rounded-lg p-3 transition-shadow ${
className={`flex items-center justify-between rounded-lg bg-accent/50 p-3 transition-shadow ${
snapshot.isDragging ? 'shadow-md' : ''
}`}
>
@@ -332,7 +332,7 @@ export const EnvelopeEditorUploadPage = () => {
<p className="text-sm font-medium">{localFile.title}</p>
)}
<div className="text-muted-foreground text-xs">
<div className="text-xs text-muted-foreground">
{localFile.isUploading ? (
<Trans>Uploading</Trans>
) : localFile.isError ? (
@@ -345,13 +345,13 @@ export const EnvelopeEditorUploadPage = () => {
<div className="flex items-center space-x-2">
{localFile.isUploading && (
<div className="flex h-6 w-10 items-center justify-center">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{localFile.isError && (
<div className="flex h-6 w-10 items-center justify-center">
<FileWarningIcon className="text-destructive h-4 w-4" />
<FileWarningIcon className="h-4 w-4 text-destructive" />
</div>
)}

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