Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 844af17ec2 | |||
| 653ab3678a | |||
| 006b1d0a57 | |||
| f3ec8ddc57 | |||
| 9a66d0ebf6 | |||
| 29622d3151 | |||
| 5de2527e54 | |||
| 6fcf0a638c | |||
| ff9e6acb7a | |||
| a60c6a90ab | |||
| f35c19d098 | |||
| cf8e21bf35 | |||
| 3f7c4df1b1 | |||
| ca199e7885 | |||
| 435d61ea57 | |||
| 34f14ba69a | |||
| 51916cd3f0 | |||
| f158305499 | |||
| 2e3d22c856 | |||
| d66c330d46 | |||
| 9bcb240895 | |||
| 066e6bc847 | |||
| 0d65693d55 | |||
| e3dee5e565 | |||
| f1c91c4951 | |||
| a5ef1d23e6 | |||
| d91414697d | |||
| e222a872d2 | |||
| e3b0087be6 | |||
| da89ce7c9a | |||
| b762561f11 | |||
| 9b190ef582 | |||
| 1669216a91 | |||
| 594a0f0c3f | |||
| 39ebc8184a | |||
| 2df41b9f01 | |||
| 8704c731c0 | |||
| eaee0d4bc6 | |||
| 0f8b7670f4 | |||
| 25e148d459 | |||
| 97ceb317a8 | |||
| c83109628d | |||
| a4d0e3e873 | |||
| 59a514c238 | |||
| 1b0df2d082 | |||
| d18dcb4d60 | |||
| d77f81163b | |||
| 62fb9e5248 | |||
| 53b0131740 | |||
| 155310b028 | |||
| 28bc2dc975 | |||
| eb3b3b18ce | |||
| 8bc4f1a713 | |||
| d3c898e317 | |||
| d08049ed3b | |||
| 7a583aa7af | |||
| b590076d85 | |||
| 65e30b88be | |||
| 9c6ee88cc4 | |||
| 6028ad9158 | |||
| 7fc6f5bb6e | |||
| 17b261df1f | |||
| c732c85082 | |||
| 7d38e18f93 | |||
| 0a3e0b8727 | |||
| b538580a1e | |||
| 42d6e1cbbd | |||
| 67da488f63 | |||
| fd3ebc08ec | |||
| a7963b385a | |||
| 9035240b4d | |||
| ed7a0011c7 | |||
| 158b36a9b7 | |||
| fabd69bd62 | |||
| c976e747e3 | |||
| 34f512bd55 | |||
| db913e95b6 | |||
| bb3e9583e4 | |||
| 5bc73a7471 | |||
| 06d7849146 | |||
| cef7987a72 | |||
| cf6f6bcea0 | |||
| 2f27304750 | |||
| 912530ca17 | |||
| a995961c4e | |||
| 6b041c23b4 | |||
| 7b6e948aa2 | |||
| f6d81b22bd | |||
| c861dd2ee2 | |||
| 7eabae4b4b | |||
| ae4272a6b6 | |||
| fd672943d1 | |||
| c2ea5e5859 | |||
| c1217c5a58 | |||
| 27eb2d65d4 | |||
| ef407cb0b4 | |||
| 1e20561e91 | |||
| a2ec5f0fa1 | |||
| de8d13a4c1 | |||
| 495d61a11d | |||
| 90fdba8000 | |||
| aa1cada79b | |||
| 790b385849 | |||
| baa2c51123 | |||
| 1e585e06e6 | |||
| 5624484631 | |||
| 810e00da03 | |||
| eeeee2fa0e | |||
| c50a31a503 | |||
| 7360709795 | |||
| df678d7d69 | |||
| 6739242554 | |||
| a5e5eecf8b | |||
| b0248c20eb | |||
| f129968968 | |||
| c5c87e3fd1 | |||
| 24a74c7b57 | |||
| f0a5a7e816 | |||
| 8462cd13fd | |||
| 576846de32 | |||
| 06071ea035 | |||
| b45a2691ba | |||
| f31cc575d0 | |||
| 05d7015ef0 | |||
| 2ca5d6cfaa | |||
| 04814ca14e | |||
| dd1dccdb6a | |||
| df4316ac5c | |||
| 02f1264eea | |||
| 928edb8645 | |||
| 54b0e4964e | |||
| 68e6ccdd19 | |||
| 09ab7e9a09 | |||
| 3bb0777914 | |||
| 4d6389e901 | |||
| 51e3d5030d | |||
| 0cebdec637 | |||
| 43486d8448 | |||
| 4d3d1b8d14 | |||
| 0387f3c20a | |||
| c5032d0c43 | |||
| 3bd34964cd | |||
| fe93b11a2c | |||
| 7638faf27b | |||
| 8fca029d96 | |||
| bac2bf11f4 | |||
| d93b2a70a7 | |||
| 5da915da38 | |||
| dcaecf1fc5 | |||
| f70b76d8b8 | |||
| 93137c6396 | |||
| d058b7c705 | |||
| b51f562224 | |||
| f80aa4bf72 | |||
| 9238f759a6 | |||
| 74ad6af47d | |||
| 18902ed59d | |||
| 3f70082146 | |||
| 31ba6d5f00 | |||
| c4f89a87a2 | |||
| 89d6dd5b0e | |||
| 08a9ab3aaf | |||
| e66bd422e3 | |||
| 0f5814ff89 | |||
| 1275a15571 | |||
| 22d99c7410 | |||
| 26a36487d4 | |||
| 2ee6b90c99 | |||
| f70e6ac50a | |||
| 7a94ee3b83 | |||
| e39924714a | |||
| c9604fee64 | |||
| 90f8340af4 | |||
| 28b8d2d415 | |||
| 978a2047d4 | |||
| 0dfa953f54 | |||
| 4774324e07 | |||
| bc19699a58 | |||
| 55480826de | |||
| 327b0eaf86 | |||
| 2de5c1992f | |||
| df0c03816e | |||
| a610a06372 | |||
| d5e085d7ee | |||
| c322356654 | |||
| b16862b480 | |||
| 7065b0dd88 | |||
| dff9cfec05 | |||
| d84cf0e58d | |||
| 5d8b147199 | |||
| 7d28295d42 | |||
| 94646cd48a | |||
| 14db9b8203 |
@@ -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,519 @@
|
||||
---
|
||||
date: 2026-02-10
|
||||
title: Envelope Expiration
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Envelopes (documents sent for signing) should automatically expire after a configurable period, preventing recipients from completing stale documents. Expiration is tracked **per-recipient** — when a recipient's signing window lapses, the document owner is notified and can resend (extending the deadline) or cancel. The document itself stays PENDING so other recipients can continue signing.
|
||||
|
||||
**Settings cascade**: Organisation → Team → Document (each level can override the prior).
|
||||
**Default**: 1 month from when the envelope is sent (transitions to PENDING).
|
||||
|
||||
---
|
||||
|
||||
## 1. Database Schema Changes
|
||||
|
||||
### 1.1 Expiration period data shape
|
||||
|
||||
Store expiration as a structured JSON object rather than an enum or raw milliseconds. This avoids the enum treadmill (adding `FOUR_MONTHS` later requires a migration) while keeping values validated and meaningful.
|
||||
|
||||
**Zod schema** (defined in `packages/lib/constants/envelope-expiration.ts`):
|
||||
|
||||
```typescript
|
||||
export const ZEnvelopeExpirationPeriod = z.union([
|
||||
z.object({ unit: z.enum(['day', 'week', 'month', 'year']), amount: z.number().int().min(1) }),
|
||||
z.object({ disabled: z.literal(true) }),
|
||||
]);
|
||||
|
||||
export type TEnvelopeExpirationPeriod = z.infer<typeof ZEnvelopeExpirationPeriod>;
|
||||
```
|
||||
|
||||
Semantics:
|
||||
|
||||
- `null` on `DocumentMeta` / `TeamGlobalSettings` = inherit from parent
|
||||
- `{ disabled: true }` = explicitly never expires
|
||||
- `{ unit: 'month', amount: 1 }` = expires in 1 month
|
||||
|
||||
No Prisma enum is needed — the period is stored as `Json?` on the relevant models (see sections 1.3 and 1.4).
|
||||
|
||||
### 1.2 Add expiration fields to `Recipient`
|
||||
|
||||
```prisma
|
||||
model Recipient {
|
||||
// ... existing fields
|
||||
expiresAt DateTime?
|
||||
expirationNotifiedAt DateTime? // null = not yet notified; set when owner notification sent
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
```
|
||||
|
||||
`expiresAt` is a computed timestamp set when the envelope transitions to PENDING (at send time). It is calculated from the effective expiration period. Storing the concrete timestamp rather than a relative duration means:
|
||||
|
||||
- Sweep queries are simple (`WHERE expiresAt <= NOW() AND expirationNotifiedAt IS NULL`)
|
||||
- No need to re-resolve the settings cascade at query time
|
||||
- The sender can see the exact deadline in the UI
|
||||
- The index on `expiresAt` ensures the expiration sweep query is efficient
|
||||
|
||||
`expirationNotifiedAt` tracks whether the owner has already been notified about this recipient's expiration, making the notification job idempotent.
|
||||
|
||||
### 1.3 Add expiration period to settings models
|
||||
|
||||
**OrganisationGlobalSettings** (JSON, application-level default):
|
||||
|
||||
```prisma
|
||||
model OrganisationGlobalSettings {
|
||||
// ... existing fields
|
||||
envelopeExpirationPeriod Json?
|
||||
}
|
||||
```
|
||||
|
||||
Prisma `@default` doesn't work for `Json` columns, so the application-level default (`{ unit: 'month', amount: 1 }`) is applied in `extractDerivedTeamSettings` / `extractDerivedDocumentMeta` when the value is null. The migration should backfill existing rows with `{ "unit": "month", "amount": 1 }`.
|
||||
|
||||
**TeamGlobalSettings** (nullable, null = inherit from org):
|
||||
|
||||
```prisma
|
||||
model TeamGlobalSettings {
|
||||
// ... existing fields
|
||||
envelopeExpirationPeriod Json?
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 Add expiration period to DocumentMeta
|
||||
|
||||
This allows per-document override during the document editing flow:
|
||||
|
||||
```prisma
|
||||
model DocumentMeta {
|
||||
// ... existing fields
|
||||
envelopeExpirationPeriod Json?
|
||||
}
|
||||
```
|
||||
|
||||
When null on DocumentMeta, the resolved team/org setting is used at send time. Validated at write time using `ZEnvelopeExpirationPeriod.nullable()`.
|
||||
|
||||
**Important**: `envelopeExpirationPeriod` on `DocumentMeta` is a user-facing preference that may be set during the draft editing flow. It does NOT determine the final expiration — that is resolved at send time (see section 2.3). The value stored here is just the user's selection in the document editor.
|
||||
|
||||
---
|
||||
|
||||
## 2. Expiration Period Resolution
|
||||
|
||||
### 2.1 Duration mapping
|
||||
|
||||
Add to `packages/lib/constants/envelope-expiration.ts` alongside the Zod schema:
|
||||
|
||||
```typescript
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
const UNIT_TO_LUXON_KEY: Record<TEnvelopeExpirationPeriod['unit'], string> = {
|
||||
day: 'days',
|
||||
week: 'weeks',
|
||||
month: 'months',
|
||||
year: 'years',
|
||||
};
|
||||
|
||||
export const DEFAULT_ENVELOPE_EXPIRATION_PERIOD: TEnvelopeExpirationPeriod = {
|
||||
unit: 'month',
|
||||
amount: 1,
|
||||
};
|
||||
|
||||
export const getEnvelopeExpirationDuration = (period: TEnvelopeExpirationPeriod): Duration => {
|
||||
return Duration.fromObject({ [UNIT_TO_LUXON_KEY[period.unit]]: period.amount });
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 Settings cascade integration
|
||||
|
||||
`extractDerivedTeamSettings()` in `packages/lib/utils/teams.ts` needs **no code changes** — it iterates `Object.keys(derivedSettings)` and overrides with non-null team values at runtime. The new `envelopeExpirationPeriod` field on both `OrganisationGlobalSettings` and `TeamGlobalSettings` will be automatically picked up.
|
||||
|
||||
Update `extractDerivedDocumentMeta()` in `packages/lib/utils/document.ts` to include the new field:
|
||||
|
||||
```typescript
|
||||
envelopeExpirationPeriod: meta.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod,
|
||||
```
|
||||
|
||||
### 2.3 Compute `expiresAt` at send time
|
||||
|
||||
The expiration period is **locked at send time** — when the envelope transitions to PENDING. The concrete `expiresAt` timestamp is computed for each recipient when the document is actually sent.
|
||||
|
||||
In `packages/lib/server-only/document/send-document.ts`:
|
||||
|
||||
```typescript
|
||||
// Resolve effective period: document meta -> team/org settings -> default
|
||||
const rawPeriod =
|
||||
envelope.documentMeta?.envelopeExpirationPeriod ?? settings.envelopeExpirationPeriod;
|
||||
|
||||
const expiresAt = resolveExpiresAt(rawPeriod);
|
||||
|
||||
// Inside the $transaction, for each recipient:
|
||||
await tx.recipient.updateMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
data: { expiresAt },
|
||||
});
|
||||
```
|
||||
|
||||
### 2.4 Compute `expiresAt` in the direct template flow
|
||||
|
||||
`create-document-from-direct-template.ts` creates envelopes directly as PENDING and then calls `sendDocument` afterward. Since `sendDocument` handles setting `expiresAt` on recipients, the direct template flow doesn't need to set it directly — `sendDocument` handles it.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cron Job Infrastructure (New)
|
||||
|
||||
The current job system is purely event-triggered. Inngest natively supports cron-triggered functions, but the local provider (used in dev and by self-hosters who don't want a third-party dependency) has no scheduling capability. This section adds cron support to the local provider to maintain feature parity.
|
||||
|
||||
### 3.1 Extend `JobDefinition` with cron support
|
||||
|
||||
Add an optional `cron` field to the trigger type in `packages/lib/jobs/client/_internal/job.ts`:
|
||||
|
||||
```typescript
|
||||
export type JobDefinition<Name extends string = string, Schema = any> = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
enabled?: boolean;
|
||||
optimizeParallelism?: boolean;
|
||||
trigger: {
|
||||
name: Name;
|
||||
schema?: z.ZodType<Schema>;
|
||||
/** Cron expression (e.g. "* * * * *"). When set, the job runs on a schedule. */
|
||||
cron?: string;
|
||||
};
|
||||
handler: (options: { payload: Schema; io: JobRunIO }) => Promise<Json | void>;
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 Inngest provider: wire up native cron
|
||||
|
||||
In `packages/lib/jobs/client/inngest.ts`, when defining a function, check for `cron`:
|
||||
|
||||
```typescript
|
||||
defineJob(job) {
|
||||
if (job.trigger.cron) {
|
||||
this._functions.push(
|
||||
this._client.createFunction(
|
||||
{ id: job.id, name: job.name },
|
||||
{ cron: job.trigger.cron },
|
||||
async ({ step, logger }) => {
|
||||
const io = convertInngestIoToJobRunIo(step, logger, this);
|
||||
await job.handler({ payload: {} as any, io });
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Existing event-triggered logic (unchanged)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Local provider: poller + deterministic `BackgroundJob` IDs
|
||||
|
||||
Use the existing `BackgroundJob` table for multi-instance dedupe instead of advisory locks. This approach keeps implementation Prisma-only (no raw SQL), works for single-instance and multi-instance deployments, and preserves existing retry/visibility behavior.
|
||||
|
||||
**On `defineJob()`**: If the job has a `cron` field, register an in-process scheduler entry and start a lightweight poller (every 30s with jitter).
|
||||
|
||||
**Each poll tick**:
|
||||
|
||||
1. Evaluate whether the cron schedule has one or more due run slots since the last tick (use a real cron parser, e.g. `cron-parser`)
|
||||
2. For each due slot, build a deterministic run ID from job ID + scheduled slot time
|
||||
3. Create a `BackgroundJob` row with that deterministic ID using Prisma
|
||||
4. If insert succeeds → enqueue via the existing local job pipeline
|
||||
5. If insert fails with Prisma `P2002` (unique violation) → another node already enqueued that run, skip
|
||||
|
||||
### 3.4 Summary of changes to the job system
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `packages/lib/jobs/client/_internal/job.ts` | Add optional `cron` field to `trigger` type |
|
||||
| `packages/lib/jobs/client/local.ts` | Add cron poller + deterministic `BackgroundJob.id` dedupe |
|
||||
| `packages/lib/jobs/client/inngest.ts` | Wire up `{ cron: ... }` in `createFunction` for cron jobs |
|
||||
| `packages/lib/jobs/client/_internal/*` | Add cron helper utilities (`getDueCronSlots`, run ID generation) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Expiration Processing
|
||||
|
||||
### 4.1 Two-job architecture
|
||||
|
||||
Expiration uses two jobs: a **sweep dispatcher** that runs on a cron schedule and finds expired recipients, and an **individual notification job** that handles the audit log, owner notification email, and webhook for a single recipient. This separation means:
|
||||
|
||||
- The sweep is lightweight and fast (just a query + N job triggers)
|
||||
- Each recipient's expiration notification is independently retryable
|
||||
- The individual jobs are idempotent — they check `expirationNotifiedAt IS NULL` before processing
|
||||
|
||||
### 4.2 Sweep job: `EXPIRE_RECIPIENTS_SWEEP_JOB`
|
||||
|
||||
A cron-triggered job that runs every minute to find and dispatch notifications for expired recipients.
|
||||
|
||||
**Definition:** `packages/lib/jobs/definitions/internal/expire-recipients-sweep.ts`
|
||||
|
||||
**Handler:** `packages/lib/jobs/definitions/internal/expire-recipients-sweep.handler.ts`
|
||||
|
||||
```typescript
|
||||
const expiredRecipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
expiresAt: { lte: new Date() },
|
||||
expirationNotifiedAt: null,
|
||||
signingStatus: { notIn: [SigningStatus.SIGNED, SigningStatus.REJECTED] },
|
||||
envelope: { status: DocumentStatus.PENDING },
|
||||
},
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
for (const recipient of expiredRecipients) {
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.notify-recipient-expired',
|
||||
payload: { recipientId: recipient.id },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Individual notification job: `NOTIFY_RECIPIENT_EXPIRED_JOB`
|
||||
|
||||
An event-triggered job that handles a single recipient's expiration.
|
||||
|
||||
**Definition:** `packages/lib/jobs/definitions/internal/notify-recipient-expired.ts`
|
||||
|
||||
**Handler:** `packages/lib/jobs/definitions/internal/notify-recipient-expired.handler.ts`
|
||||
|
||||
The handler:
|
||||
|
||||
1. Fetches the recipient (with guard: `expirationNotifiedAt IS NULL` + not signed/rejected)
|
||||
2. Sets `recipient.expirationNotifiedAt = now()` (idempotency)
|
||||
3. Creates audit log entry with `DOCUMENT_RECIPIENT_EXPIRED` type
|
||||
4. Sends email notification to the **document owner** (inline — no separate email job)
|
||||
5. The document stays PENDING — the owner decides whether to resend or cancel
|
||||
|
||||
### 4.4 Register in job client
|
||||
|
||||
Add `EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION` and `NOTIFY_RECIPIENT_EXPIRED_JOB_DEFINITION` to the job registry in `packages/lib/jobs/client.ts`.
|
||||
|
||||
### 4.5 Email template: Recipient Expired
|
||||
|
||||
Target the **document owner**:
|
||||
|
||||
- Subject: `Signing window expired for "{recipientName}" on "{documentTitle}"`
|
||||
- Body: "The signing window for {recipientName} ({recipientEmail}) on document {title} has expired. You can resend the document to extend their deadline or cancel the document."
|
||||
- Include a "View Document" link to the document page in the app
|
||||
|
||||
Template files:
|
||||
|
||||
- `packages/email/templates/recipient-expired.tsx` — wrapper
|
||||
- `packages/email/template-components/template-recipient-expired.tsx` — body
|
||||
|
||||
### 4.6 Recipient signing guard
|
||||
|
||||
In the signing flow, check `recipient.expiresAt` before allowing any signing action. Note that the document stays PENDING even after recipient expiration, so the existing `status !== PENDING` guard does not block expired recipients — an explicit expiration check is required:
|
||||
|
||||
```typescript
|
||||
if (recipient.expiresAt && recipient.expiresAt <= new Date()) {
|
||||
throw new AppError(AppErrorCode.RECIPIENT_EXPIRED, {
|
||||
message: 'Recipient signing window has expired',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Files to update:**
|
||||
|
||||
- `packages/lib/server-only/document/complete-document-with-token.ts`
|
||||
- `packages/lib/server-only/field/sign-field-with-token.ts`
|
||||
- `packages/lib/server-only/field/remove-signed-field-with-token.ts`
|
||||
- `packages/lib/server-only/document/reject-document-with-token.ts`
|
||||
|
||||
---
|
||||
|
||||
## 5. UI Design
|
||||
|
||||
### 5.1 Expiration Period Selector Component
|
||||
|
||||
Use a number input + unit selector combo. This gives organisations full flexibility to configure any duration without needing schema changes for new options.
|
||||
|
||||
**Layout**: A horizontal group with:
|
||||
|
||||
- A number `<Input>` (min 1, integer)
|
||||
- A `<Select>` for the unit (`day`, `week`, `month`, `year`)
|
||||
- A "Never expires" toggle/checkbox that disables the duration inputs and sets the value to `{ disabled: true }`
|
||||
|
||||
At the team level, include an "Inherit from organisation" option that clears the value to `null`.
|
||||
|
||||
**Validation**: Use `ZEnvelopeExpirationPeriod` for form validation.
|
||||
|
||||
### 5.2 Organisation Settings → Document Preferences
|
||||
|
||||
Add a "Default Envelope Expiration" field to the `DocumentPreferencesForm` component. At the org level, there is no "Inherit" option — it must have a concrete value (default: `{ unit: 'month', amount: 1 }`).
|
||||
|
||||
### 5.3 Team Settings → Document Preferences
|
||||
|
||||
Same field as org, but with the additional "Inherit from organisation" option (stored as `null`).
|
||||
|
||||
### 5.4 Document Editor → Settings Step
|
||||
|
||||
Add the expiration selector to `packages/ui/primitives/document-flow/add-settings.tsx` inside the "Advanced Options" accordion.
|
||||
|
||||
Label: **"Expiration"**
|
||||
Description: _"How long recipients have to complete this document after it is sent."_
|
||||
|
||||
### 5.5 Recipient Signing Page — Expired State
|
||||
|
||||
When a recipient visits a signing link for an expired recipient:
|
||||
|
||||
- Redirect to `/sign/{token}/expired`
|
||||
- Show a clear, non-alarming message: "Your signing window has expired. Please contact the sender for a new invitation."
|
||||
- Do not show the signing form or fields
|
||||
- The `isExpired` flag in `get-envelope-for-recipient-signing.ts` is derived from `recipient.expiresAt`
|
||||
|
||||
### 5.6 Embed Signing — Expired State
|
||||
|
||||
Embed signing routes handle recipient expiration by throwing `embed-recipient-expired`:
|
||||
|
||||
- `apps/remix/app/routes/embed+/_v0+/sign.$token.tsx` — both V1 and V2 loaders check expiration
|
||||
- The embed error boundary renders an `EmbedRecipientExpired` component
|
||||
- Direct templates (`direct.$token.tsx`) create fresh recipients so `isExpired` is always `false`
|
||||
|
||||
---
|
||||
|
||||
## 6. API / TRPC Changes
|
||||
|
||||
### 6.1 Update settings mutation schemas
|
||||
|
||||
- `packages/trpc/server/organisation-router/update-organisation-settings.types.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod` (non-nullable at org level)
|
||||
- `packages/trpc/server/team-router/update-team-settings.types.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable()` (null = inherit from org)
|
||||
|
||||
### 6.2 Update document mutation schemas
|
||||
|
||||
- `packages/lib/types/document-meta.ts` — add `envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullable()` to the meta schema
|
||||
- `packages/trpc/server/document-router/create-document.types.ts` — include in meta
|
||||
- `packages/trpc/server/document-router/update-document.types.ts` — include in meta
|
||||
- `packages/trpc/server/document-router/distribute-document.types.ts` — include in meta
|
||||
|
||||
### 6.3 Expose `expiresAt` in recipient responses
|
||||
|
||||
Ensure `expiresAt` and `expirationNotifiedAt` are returned when fetching recipients/documents so the UI can display expiration status.
|
||||
|
||||
### 6.4 Webhook / API schema updates
|
||||
|
||||
- Recipient schema includes `expiresAt` and `expirationNotifiedAt` fields (replacing the old `expired` field)
|
||||
- Update `packages/api/v1/schema.ts`, webhook payload types, zapier integration, and sample data generators
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge Cases & Considerations
|
||||
|
||||
### 7.1 Already-sent documents
|
||||
|
||||
The migration should NOT retroactively expire existing recipients. `expiresAt` will be null for all existing recipients, meaning they never expire (backward-compatible).
|
||||
|
||||
### 7.2 Re-sending / redistributing
|
||||
|
||||
When `redistribute` is called on a PENDING document, `expiresAt` should be refreshed on all eligible recipients. Redistributing signals active intent, so the clock should restart.
|
||||
|
||||
**Implementation**: `resendDocument` refreshes `recipient.expiresAt` for all recipients that haven't signed/rejected yet.
|
||||
|
||||
### 7.3 Multi-recipient partial expiration
|
||||
|
||||
If some recipients have signed and others expire, the document stays PENDING. This is the key advantage over document-level expiration — the owner can resend to extend the expired recipients' deadlines without affecting those who've already signed.
|
||||
|
||||
### 7.4 Partial completion
|
||||
|
||||
Partial signatures are preserved. The document is not sealed/completed until all required recipients have signed (or the owner cancels).
|
||||
|
||||
### 7.5 Timezone handling
|
||||
|
||||
`expiresAt` is stored as UTC. Display in the sender's configured timezone.
|
||||
|
||||
### 7.6 Race condition: signing at expiration time
|
||||
|
||||
The signing guard checks `recipient.expiresAt` in application code before the signing operation. The notification job's guard (`expirationNotifiedAt IS NULL` + `signingStatus NOT IN (SIGNED, REJECTED)`) prevents double-notifications. If a recipient signs just before expiration, the sweep's `signingStatus` filter skips them.
|
||||
|
||||
### 7.7 Direct template flow
|
||||
|
||||
`create-document-from-direct-template.ts` creates envelopes directly as PENDING then calls `sendDocument`. Since `sendDocument` sets `recipient.expiresAt`, no special handling is needed in the direct template flow.
|
||||
|
||||
---
|
||||
|
||||
## 8. Migration Plan
|
||||
|
||||
1. Add Prisma schema changes (`expiresAt` + `expirationNotifiedAt` on Recipient, `Json?` fields on settings models, index)
|
||||
2. Generate and run migration
|
||||
3. Backfill: set `envelopeExpirationPeriod` to `{ "unit": "month", "amount": 1 }` on all existing `OrganisationGlobalSettings` rows
|
||||
4. No backfill on `Recipient.expiresAt` — existing recipients keep null (never expire)
|
||||
5. Deploy backend changes (jobs, guards, email template)
|
||||
6. Deploy frontend changes (settings UI, document editor, signing page, embeds)
|
||||
|
||||
---
|
||||
|
||||
## 9. Files to Create or Modify
|
||||
|
||||
### New Files
|
||||
|
||||
- `packages/lib/constants/envelope-expiration.ts` — `ZEnvelopeExpirationPeriod` schema, types, `DEFAULT_ENVELOPE_EXPIRATION_PERIOD`, `getEnvelopeExpirationDuration()`, `resolveExpiresAt()` helper
|
||||
- `packages/lib/jobs/definitions/internal/expire-recipients-sweep.ts` — cron sweep job definition
|
||||
- `packages/lib/jobs/definitions/internal/expire-recipients-sweep.handler.ts` — cron sweep handler
|
||||
- `packages/lib/jobs/definitions/internal/notify-recipient-expired.ts` — individual notification job definition
|
||||
- `packages/lib/jobs/definitions/internal/notify-recipient-expired.handler.ts` — notification handler (includes inline email sending)
|
||||
- `packages/email/templates/recipient-expired.tsx` — email template wrapper
|
||||
- `packages/email/template-components/template-recipient-expired.tsx` — email template body
|
||||
- `apps/remix/app/components/embed/embed-recipient-expired.tsx` — embed expired component
|
||||
|
||||
### Modified Files
|
||||
|
||||
**Job system (cron infrastructure):**
|
||||
|
||||
- `packages/lib/jobs/client/_internal/job.ts` — add optional `cron` field to `trigger` type
|
||||
- `packages/lib/jobs/client/local.ts` — add cron poller + deterministic `BackgroundJob.id` dedupe
|
||||
- `packages/lib/jobs/client/inngest.ts` — wire up `{ cron: ... }` in `createFunction`
|
||||
- `packages/lib/jobs/client/_internal/*` — add cron helper utilities (slot calc + run ID)
|
||||
- `packages/lib/jobs/client.ts` — register new jobs
|
||||
|
||||
**Schema & data layer:**
|
||||
|
||||
- `packages/prisma/schema.prisma` — model changes + index
|
||||
- `packages/lib/utils/document.ts` — `extractDerivedDocumentMeta` (add `envelopeExpirationPeriod`)
|
||||
- `packages/lib/server-only/document/send-document.ts` — resolve settings + compute and set `recipient.expiresAt`
|
||||
- `packages/lib/server-only/template/create-document-from-direct-template.ts` — no changes (sendDocument handles it)
|
||||
- `packages/lib/server-only/document/resend-document.ts` — refresh `recipient.expiresAt` on redistribute
|
||||
- `packages/lib/server-only/document/complete-document-with-token.ts` — recipient expiration guard
|
||||
- `packages/lib/server-only/field/sign-field-with-token.ts` — recipient expiration guard
|
||||
- `packages/lib/server-only/field/remove-signed-field-with-token.ts` — recipient expiration guard
|
||||
- `packages/lib/server-only/document/reject-document-with-token.ts` — recipient expiration guard
|
||||
|
||||
**Error handling:**
|
||||
|
||||
- `packages/lib/errors/app-error.ts` — add `RECIPIENT_EXPIRED` error code
|
||||
|
||||
**Audit logs:**
|
||||
|
||||
- `packages/lib/types/document-audit-logs.ts` — add `DOCUMENT_RECIPIENT_EXPIRED` type with `recipientEmail`/`recipientName` data fields
|
||||
- `packages/lib/utils/document-audit-logs.ts` — add human-readable rendering for `DOCUMENT_RECIPIENT_EXPIRED`
|
||||
|
||||
**Signing page:**
|
||||
|
||||
- `packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts` — derive `isExpired` from `recipient.expiresAt`
|
||||
- `apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx` — keep redirect to expired page using `isExpired`
|
||||
|
||||
**Embeds:**
|
||||
|
||||
- `apps/remix/app/routes/embed+/_v0+/sign.$token.tsx` — check recipient expiration in V1/V2 loaders
|
||||
- `apps/remix/app/routes/embed+/_v0+/_layout.tsx` — handle `embed-recipient-expired` in error boundary
|
||||
|
||||
**Webhook / API:**
|
||||
|
||||
- `packages/lib/types/recipient.ts` — add `expiresAt`/`expirationNotifiedAt` to recipient type
|
||||
- `packages/lib/types/webhook-payload.ts` — add `expiresAt`/`expirationNotifiedAt` to webhook recipient
|
||||
- `packages/lib/server-only/webhooks/trigger/generate-sample-data.ts` — update sample data
|
||||
- `packages/lib/server-only/webhooks/zapier/list-documents.ts` — update zapier recipient shape
|
||||
- `packages/api/v1/schema.ts` — add `expiresAt` to API recipient schema
|
||||
|
||||
**TRPC / settings:**
|
||||
|
||||
- `packages/trpc/server/organisation-router/update-organisation-settings.types.ts`
|
||||
- `packages/trpc/server/team-router/update-team-settings.types.ts`
|
||||
- `packages/lib/types/document-meta.ts`
|
||||
|
||||
**UI:**
|
||||
|
||||
- `apps/remix/app/components/forms/document-preferences-form.tsx` — add expiration period picker
|
||||
- `packages/ui/primitives/document-flow/add-settings.tsx` — add expiration field
|
||||
- `packages/ui/primitives/document-flow/add-settings.types.ts` — add to schema
|
||||
@@ -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,168 @@
|
||||
---
|
||||
date: 2026-02-11
|
||||
title: Cert Page Width Mismatch
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Certificate and audit log pages are generated with hardcoded A4 dimensions (`PDF_SIZE_A4_72PPI`: 595×842) regardless of the actual document page sizes. When the source document uses a different page size (e.g., Letter, Legal, or custom dimensions), the certificate/audit log pages end up with a different width than the document pages. This causes problems with courts that expect uniform page dimensions throughout a PDF.
|
||||
|
||||
**Both width and height must match** the last page of the document so the entire PDF prints uniformly.
|
||||
|
||||
**Root cause**: In `seal-document.handler.ts` (lines 186-187), the certificate payload always uses:
|
||||
|
||||
```ts
|
||||
pageWidth: PDF_SIZE_A4_72PPI.width, // 595
|
||||
pageHeight: PDF_SIZE_A4_72PPI.height, // 842
|
||||
```
|
||||
|
||||
These hardcoded values flow into `generateCertificatePdf`, `generateAuditLogPdf`, `renderCertificate`, and `renderAuditLogs` — all of which use `pageWidth`/`pageHeight` to set Konva stage dimensions and layout content.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `packages/lib/jobs/definitions/internal/seal-document.handler.ts` | Orchestrates sealing; passes page dimensions to cert/audit generators |
|
||||
| `packages/lib/constants/pdf.ts` | Defines `PDF_SIZE_A4_72PPI` (595×842) |
|
||||
| `packages/lib/server-only/pdf/generate-certificate-pdf.ts` | Generates certificate PDF; accepts `pageWidth`/`pageHeight` |
|
||||
| `packages/lib/server-only/pdf/generate-audit-log-pdf.ts` | Generates audit log PDF; accepts `pageWidth`/`pageHeight` |
|
||||
| `packages/lib/server-only/pdf/render-certificate.ts` | Renders certificate pages via Konva; uses `pageWidth`/`pageHeight` for stage + layout |
|
||||
| `packages/lib/server-only/pdf/render-audit-logs.ts` | Renders audit log pages via Konva; uses `pageWidth`/`pageHeight` for stage + layout |
|
||||
| `packages/lib/server-only/pdf/get-page-size.ts` | Existing utility — extend with `@libpdf/core` version |
|
||||
| `packages/trpc/server/document-router/download-document-certificate.ts` | Standalone certificate download (also hardcodes A4) |
|
||||
| `packages/trpc/server/document-router/download-document-audit-logs.ts` | Standalone audit log download (also hardcodes A4) |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Current Flow
|
||||
|
||||
1. **One cert PDF + one audit log PDF** generated per envelope with hardcoded A4 dims
|
||||
2. Both appended to **every** envelope item (document) via `decorateAndSignPdf` → `pdfDoc.copyPagesFrom()`
|
||||
3. The audit log is envelope-level (all recipients, all events across all docs) — one per envelope, not per document
|
||||
|
||||
### Multi-Document Envelopes
|
||||
|
||||
- V1 envelopes: single document only
|
||||
- V2 envelopes: support multiple documents (envelope items)
|
||||
- Each envelope item gets both cert + audit log pages appended to it
|
||||
- If documents have different page sizes → need size-matched cert/audit for each
|
||||
|
||||
### Reading Page Dimensions (`@libpdf/core` only)
|
||||
|
||||
Use `@libpdf/core`'s `PDF` class — NOT `@cantoo/pdf-lib`:
|
||||
|
||||
```ts
|
||||
const pdfDoc = await PDF.load(pdfData);
|
||||
const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1);
|
||||
const { width, height } = lastPage; // e.g. 612, 792 for Letter
|
||||
```
|
||||
|
||||
Already used this way in `seal-document.handler.ts` lines 403-410 for V2 field insertion.
|
||||
"Last page" = last page of the original document, before cert/audit pages are appended.
|
||||
|
||||
### Content Layout Adaptation
|
||||
|
||||
Both renderers already handle variable dimensions gracefully:
|
||||
|
||||
- **Width**: `render-certificate.ts:713` / `render-audit-logs.ts:588` — `Math.min(pageWidth - minimumMargin * 2, contentMaxWidth)` with `contentMaxWidth = 768`. Wider pages get more margin, narrower pages tighter margins.
|
||||
- **Height**: Both renderers paginate content into pages using `groupRowsIntoPages()` which respects `pageHeight` via `maxTableHeight = pageHeight - pageTopMargin - pageBottomMargin`. Shorter pages just mean more pages; taller pages fit more rows per page.
|
||||
|
||||
### Playwright PDF Path — Out of Scope
|
||||
|
||||
The `NEXT_PRIVATE_USE_PLAYWRIGHT_PDF` toggle enables a deprecated Playwright-based PDF generation path (`get-certificate-pdf.ts`, `get-audit-logs-pdf.ts`) that also hardcodes `format: 'A4'` in `page.pdf()`. This path is **not being updated** as part of this fix:
|
||||
|
||||
- Both files are marked `@deprecated`
|
||||
- The Konva-based path is the default and recommended path
|
||||
- The Playwright path is behind a feature flag and will be removed
|
||||
|
||||
No changes needed. Add a code comment noting the A4 limitation if the Playwright path is ever re-enabled.
|
||||
|
||||
## Plan
|
||||
|
||||
### 1. Extend `get-page-size.ts` with `@libpdf/core` utility
|
||||
|
||||
Add a `getLastPageDimensions` function to the existing `packages/lib/server-only/pdf/get-page-size.ts` file. This consolidates page-size logic in one place (the file already has the legacy `@cantoo/pdf-lib` version).
|
||||
|
||||
```ts
|
||||
export const getLastPageDimensions = (pdfDoc: PDF): { width: number; height: number } => {
|
||||
const lastPage = pdfDoc.getPage(pdfDoc.getPageCount() - 1);
|
||||
const width = Math.round(lastPage.width);
|
||||
const height = Math.round(lastPage.height);
|
||||
|
||||
if (width < MIN_CERT_PAGE_WIDTH || height < MIN_CERT_PAGE_HEIGHT) {
|
||||
return { width: PDF_SIZE_A4_72PPI.width, height: PDF_SIZE_A4_72PPI.height };
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
```
|
||||
|
||||
**Dimension rounding**: `Math.round()` both width and height. PDF points at 72ppi are typically whole numbers; rounding avoids spurious float-precision mismatches (e.g., 612.0 vs 612.00001) that would cause unnecessary duplicate cert/audit PDF generation.
|
||||
|
||||
**Minimum page dimensions**: Enforce a minimum threshold (e.g., 300pt for both width and height). If either dimension falls below the minimum, fall back to A4 (595×842). The certificate and audit log renderers have headers, table rows, margins, and QR codes that require a minimum viable area.
|
||||
|
||||
### 2. Read last page dimensions from each envelope item's PDF
|
||||
|
||||
In `seal-document.handler.ts`, before generating cert/audit PDFs:
|
||||
|
||||
- For each `envelopeItem`, load the PDF and read the **last page's width and height** using `getLastPageDimensions`
|
||||
- Use `PDF.load()` then pass the loaded doc to the utility
|
||||
|
||||
**Resealing consideration**: When `isResealing` is true, envelope items are remapped to use `initialData` (lines 152-158) before this point. Page-size extraction must operate on the same data source that `decorateAndSignPdf` will use. Since the `envelopeItems` array is already remapped by the time we read dimensions, reading from `envelopeItem.documentData` will naturally give the correct (initial) data. No special handling needed beyond ensuring the dimension read happens **after** the resealing remap.
|
||||
|
||||
### 3. Generate cert/audit PDFs per unique page size
|
||||
|
||||
Current flow generates one cert + one audit log doc per envelope. Change to:
|
||||
|
||||
1. Collect `{ width, height }` of the last page for each envelope item
|
||||
2. Deduplicate by `"${width}x${height}"` key (using the already-rounded integers)
|
||||
3. For each unique size, generate cert PDF and audit log PDF with those dimensions
|
||||
4. Store in a `Map<string, { certificateDoc, auditLogDoc }>` keyed by `"${width}x${height}"`
|
||||
|
||||
For the common single-document case, this is one generation — same perf as today.
|
||||
|
||||
### 4. Thread the correct docs into `decorateAndSignPdf`
|
||||
|
||||
In the envelope item loop, look up the item's last-page dimensions in the map and pass the matching cert/audit docs. Signature of `decorateAndSignPdf` doesn't change — it still receives a single `certificateDoc` and `auditLogDoc`, just the right ones per item.
|
||||
|
||||
### 5. Update standalone download routes
|
||||
|
||||
`download-document-certificate.ts` and `download-document-audit-logs.ts` also hardcode A4:
|
||||
|
||||
- Both routes have `documentId` which maps to a specific envelope item
|
||||
- Fetch **that specific document's** PDF data, load it, read last page width + height via `getLastPageDimensions`
|
||||
- Pass `{ pageWidth, pageHeight }` to the generator
|
||||
- This ensures the standalone download matches the dimensions the user would see in the sealed PDF for that document
|
||||
|
||||
### 6. Edge cases
|
||||
|
||||
| Scenario | Behavior |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Mixed page sizes within one PDF | Use last page's dimensions (per spec) |
|
||||
| Page dimensions below minimum threshold | Fall back to A4 (595×842) |
|
||||
| Landscape pages | width/height just swap roles; renderers adapt via `Math.min()` capping. No special handling |
|
||||
| Fallback if page dims unreadable | Default to A4 (595×842) |
|
||||
| Resealing | Dimensions read after `initialData` remap — correct source automatically |
|
||||
| Playwright PDF path enabled | Remains A4 — out of scope, deprecated |
|
||||
| Single-doc envelope (most common) | One generation, same perf as today |
|
||||
| Multi-doc envelope, same page sizes | Dedup key matches → one generation |
|
||||
| Multi-doc envelope, different sizes | One generation per unique size |
|
||||
|
||||
### 7. Tests
|
||||
|
||||
- Add assertion-based E2E test (no visual regression / reference images needed)
|
||||
- Seal a Letter-size (612×792) PDF through the full flow
|
||||
- Load the sealed output and assert all pages (document + cert + audit) have matching width/height
|
||||
- Can be added to `envelope-alignment.spec.ts` or as a new focused test
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Extend `get-page-size.ts`** — add `getLastPageDimensions(pdfDoc: PDF): { width: number; height: number }` using `@libpdf/core`, with `Math.round()` and minimum dimension enforcement
|
||||
2. **In `seal-document.handler.ts`**:
|
||||
a. After the resealing remap (line ~159), load each envelope item's PDF via `PDF.load()` and collect last page `{ width, height }` using `getLastPageDimensions`
|
||||
b. Deduplicate by `"${width}x${height}"` key
|
||||
c. Generate cert/audit PDFs per unique size (parallel via `Promise.all`)
|
||||
d. In envelope item loop, look up matching cert/audit doc by size key
|
||||
3. **Fix `download-document-certificate.ts`** — load the specific document's PDF, read last page dims via `getLastPageDimensions`, pass to generator
|
||||
4. **Fix `download-document-audit-logs.ts`** — same as above, using the specific `documentId`'s PDF
|
||||
5. **Add E2E test** — assertion-based test with a Letter-size document verifying all page dimensions match after sealing
|
||||
@@ -0,0 +1,551 @@
|
||||
---
|
||||
date: 2026-02-19
|
||||
title: Database Rate Limiting
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the in-memory `hono-rate-limiter` with a database-backed rate limiting system using Prisma and PostgreSQL. The current in-memory approach is ineffective in multi-instance deployments since there are no sticky sessions. The new system uses **bucketed counters** (one row per key/action/time-bucket with atomic increment) to efficiently handle both high-throughput API rate limiting and granular auth/email rate limiting.
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- **Bucketed counters** over row-per-request: high-throughput consumers would create thousands of rows per minute; bucketed counters reduce this to one row per key per time bucket
|
||||
- **Fixed time windows**: simpler than sliding windows, the 2x burst-at-boundary scenario is acceptable for rate limiting purposes
|
||||
- **Dual-key rate limiting**: per-identifier (`max`) and per-IP (`globalMax`) checked independently via separate rows with a `key` prefix (`id:` / `ip:`)
|
||||
- **Accept slight over-count**: the upsert is atomic (increment + return count in one operation) but concurrent requests near the limit may both see a count just under the threshold before either commits, allowing a slight overshoot
|
||||
- **Fail-open on errors**: if the rate limit DB query fails, allow the request through rather than blocking legitimate users
|
||||
- **Prisma upsert** with `{ increment: 1 }` for atomic counter updates, returns the updated row so count check is a single operation
|
||||
- **Application cron job** for cleanup of expired bucket rows
|
||||
|
||||
### Rate Limit Check Flow
|
||||
|
||||
```
|
||||
check({ ip, identifier }) ->
|
||||
1. Upsert IP row (ip:{ip} / action / bucket) with count + 1, RETURNING count
|
||||
-> if globalMax is set and count >= globalMax, return { isLimited: true }
|
||||
2. Upsert identifier row (id:{identifier} / action / bucket) with count + 1, RETURNING count
|
||||
-> if count >= max, return { isLimited: true }
|
||||
3. Neither limited -> return { isLimited: false }
|
||||
```
|
||||
|
||||
Each upsert atomically increments and returns the new count in a single operation. Both counters always increment on every check — there's no conditional logic to skip one based on the other. This keeps the implementation simple and avoids read-then-write race conditions. If only IP is provided (API rate limiting), only step 1 runs.
|
||||
|
||||
---
|
||||
|
||||
## 1. Database Schema
|
||||
|
||||
### 1.1 Prisma model
|
||||
|
||||
Add to `packages/prisma/schema.prisma` after the `Counter` model:
|
||||
|
||||
```prisma
|
||||
model RateLimit {
|
||||
key String
|
||||
action String
|
||||
bucket DateTime
|
||||
count Int @default(1)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([key, action, bucket])
|
||||
@@index([createdAt])
|
||||
}
|
||||
```
|
||||
|
||||
- **Composite primary key** `(key, action, bucket)` serves as both the unique constraint for upserts and the lookup index
|
||||
- **`key`** is prefixed: `ip:1.2.3.4` or `id:user@example.com`
|
||||
- **`action`** is the rate limit action name: `auth.forgot-password`, `api.v1`, etc.
|
||||
- **`bucket`** is the start of the time window, truncated to the window size (e.g., `2026-02-19T10:05:00Z` for a 5-minute bucket)
|
||||
- **`createdAt` index** is for the cleanup job to efficiently delete old rows
|
||||
- **`count`** starts at 1 (set by the create side of the upsert)
|
||||
|
||||
### 1.2 Migration
|
||||
|
||||
Generate with `npx prisma migrate dev --name add-rate-limits`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rate Limit Library
|
||||
|
||||
### 2.1 Core module
|
||||
|
||||
Create `packages/lib/server-only/rate-limit/rate-limit.ts`:
|
||||
|
||||
```typescript
|
||||
type WindowUnit = 's' | 'm' | 'h' | 'd';
|
||||
type WindowStr = `${number}${WindowUnit}`;
|
||||
|
||||
type RateLimitConfig = {
|
||||
action: string;
|
||||
max: number;
|
||||
globalMax?: number;
|
||||
window: WindowStr;
|
||||
};
|
||||
|
||||
type CheckParams = {
|
||||
ip: string;
|
||||
identifier?: string;
|
||||
};
|
||||
|
||||
export const rateLimit = (config: RateLimitConfig) => {
|
||||
return {
|
||||
async check(params: CheckParams): Promise<{
|
||||
isLimited: boolean;
|
||||
remaining: number;
|
||||
limit: number;
|
||||
reset: Date;
|
||||
}> { ... }
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 Window parsing and bucket computation
|
||||
|
||||
```typescript
|
||||
const parseWindow = (window: WindowStr): number => {
|
||||
const value = parseInt(window.slice(0, -1), 10);
|
||||
const unit = window.slice(-1) as WindowUnit;
|
||||
const multipliers: Record<WindowUnit, number> = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
return value * multipliers[unit];
|
||||
};
|
||||
|
||||
const getBucket = (windowMs: number): Date => {
|
||||
const now = Date.now();
|
||||
return new Date(now - (now % windowMs));
|
||||
};
|
||||
```
|
||||
|
||||
### 2.3 Check implementation
|
||||
|
||||
The `check()` method:
|
||||
|
||||
1. Compute the current bucket from the window
|
||||
2. Compute `reset` as `bucket + windowMs` (the start of the next window)
|
||||
3. If `globalMax` is set, upsert the IP row and check count
|
||||
4. If `identifier` is provided, upsert the identifier row and check count
|
||||
5. Wrap in try/catch — **fail-open** on any database error (log the error, return `{ isLimited: false }`)
|
||||
|
||||
Each upsert uses Prisma's `upsert` with `{ increment: 1 }`:
|
||||
|
||||
```typescript
|
||||
const result = await prisma.rateLimit.upsert({
|
||||
where: {
|
||||
key_action_bucket: {
|
||||
key: `ip:${params.ip}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
key: `ip:${params.ip}`,
|
||||
action: config.action,
|
||||
bucket,
|
||||
count: 1,
|
||||
},
|
||||
update: {
|
||||
count: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
if (config.globalMax && result.count >= config.globalMax) {
|
||||
return { isLimited: true, remaining: 0, limit: config.globalMax };
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Rate limit definitions
|
||||
|
||||
Create `packages/lib/server-only/rate-limit/rate-limits.ts` with all rate limit instances:
|
||||
|
||||
```typescript
|
||||
// ---- Auth (Tier 1 - Critical, sends emails) ----
|
||||
export const signupRateLimit = rateLimit({
|
||||
action: 'auth.signup',
|
||||
max: 5,
|
||||
globalMax: 10,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const forgotPasswordRateLimit = rateLimit({
|
||||
action: 'auth.forgot-password',
|
||||
max: 3,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const resendVerifyEmailRateLimit = rateLimit({
|
||||
action: 'auth.resend-verify-email',
|
||||
max: 3,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const request2FAEmailRateLimit = rateLimit({
|
||||
action: 'auth.request-2fa-email',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
// ---- Auth (Tier 2 - Unauthenticated) ----
|
||||
export const loginRateLimit = rateLimit({
|
||||
action: 'auth.login',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const resetPasswordRateLimit = rateLimit({
|
||||
action: 'auth.reset-password',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
export const verifyEmailRateLimit = rateLimit({
|
||||
action: 'auth.verify-email',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const passkeyRateLimit = rateLimit({
|
||||
action: 'auth.passkey',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const oauthRateLimit = rateLimit({
|
||||
action: 'auth.oauth',
|
||||
max: 10,
|
||||
globalMax: 50,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
export const linkOrgAccountRateLimit = rateLimit({
|
||||
action: 'auth.link-org-account',
|
||||
max: 5,
|
||||
globalMax: 20,
|
||||
window: '1h',
|
||||
});
|
||||
|
||||
// ---- API (Tier 4 - Standard) ----
|
||||
export const apiV1RateLimit = rateLimit({
|
||||
action: 'api.v1',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const apiV2RateLimit = rateLimit({
|
||||
action: 'api.v2',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const apiTrpcRateLimit = rateLimit({
|
||||
action: 'api.trpc',
|
||||
max: 100,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const aiRateLimit = rateLimit({
|
||||
action: 'api.ai',
|
||||
max: 3,
|
||||
window: '1m',
|
||||
});
|
||||
|
||||
export const fileUploadRateLimit = rateLimit({
|
||||
action: 'api.file-upload',
|
||||
max: 20,
|
||||
window: '1m',
|
||||
});
|
||||
```
|
||||
|
||||
Exact limits are initial values — tune based on observed traffic patterns. These should be easy to adjust.
|
||||
|
||||
---
|
||||
|
||||
## 3. Integration Points
|
||||
|
||||
### 3.1 Hono middleware for API routes
|
||||
|
||||
Create a reusable Hono middleware factory in `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` that wraps the `rateLimit` checker into Hono middleware:
|
||||
|
||||
```typescript
|
||||
import { type MiddlewareHandler } from 'hono';
|
||||
|
||||
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||
|
||||
export const createRateLimitMiddleware = (
|
||||
limiter: ReturnType<typeof rateLimit>,
|
||||
options?: { identifierFn?: (c: Context) => string | undefined },
|
||||
): MiddlewareHandler => {
|
||||
return async (c, next) => {
|
||||
let ip: string;
|
||||
try {
|
||||
ip = getIpAddress(c.req.raw);
|
||||
} catch {
|
||||
ip = 'unknown';
|
||||
}
|
||||
|
||||
const identifier = options?.identifierFn?.(c);
|
||||
|
||||
const result = await limiter.check({ ip, identifier });
|
||||
|
||||
c.header('X-RateLimit-Limit', String(result.limit));
|
||||
c.header('X-RateLimit-Remaining', String(result.remaining));
|
||||
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
|
||||
|
||||
if (result.isLimited) {
|
||||
c.header('Retry-After', String(Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
|
||||
return c.json({ error: 'Too many requests, please try again later.' }, 429);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 Replace existing Hono rate limiters
|
||||
|
||||
In `apps/remix/server/router.ts`:
|
||||
|
||||
- Remove `hono-rate-limiter` import and both `rateLimiter()` instances
|
||||
- Replace with `createRateLimitMiddleware()` calls using the defined rate limits
|
||||
- API routes use IP-only limiting (no identifier)
|
||||
- AI route uses IP-only limiting with the stricter 3/min limit
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
import { rateLimiter } from 'hono-rate-limiter';
|
||||
const rateLimitMiddleware = rateLimiter({ ... });
|
||||
|
||||
// After
|
||||
import { createRateLimitMiddleware } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import { apiV1RateLimit, apiV2RateLimit, aiRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
|
||||
const apiV1RateLimitMiddleware = createRateLimitMiddleware(apiV1RateLimit);
|
||||
const apiV2RateLimitMiddleware = createRateLimitMiddleware(apiV2RateLimit);
|
||||
const aiRateLimitMiddleware = createRateLimitMiddleware(aiRateLimit);
|
||||
```
|
||||
|
||||
### 3.3 Response helpers for inline checks
|
||||
|
||||
For auth routes (Hono handlers) and tRPC routes where rate limiting is applied inline rather than via middleware, provide helpers that handle the response formatting and headers consistently.
|
||||
|
||||
**Hono helper** — returns a 429 `Response` with headers if limited, or `null` if allowed:
|
||||
|
||||
```typescript
|
||||
export const rateLimitResponse = (c: Context, result: RateLimitCheckResult): Response | null => {
|
||||
c.header('X-RateLimit-Limit', String(result.limit));
|
||||
c.header('X-RateLimit-Remaining', String(result.remaining));
|
||||
c.header('X-RateLimit-Reset', String(Math.ceil(result.reset.getTime() / 1000)));
|
||||
|
||||
if (result.isLimited) {
|
||||
c.header('Retry-After', String(Math.ceil((result.reset.getTime() - Date.now()) / 1000)));
|
||||
return c.json({ error: 'Too many requests, please try again later.' }, 429);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
```
|
||||
|
||||
Usage in auth routes:
|
||||
|
||||
```typescript
|
||||
const result = await loginRateLimit.check({
|
||||
ip: requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: input.email,
|
||||
});
|
||||
|
||||
const limited = rateLimitResponse(c, result);
|
||||
if (limited) return limited;
|
||||
```
|
||||
|
||||
**tRPC helper** — throws a `TRPCError` with rate limit headers if limited:
|
||||
|
||||
```typescript
|
||||
export const assertRateLimit = (result: RateLimitCheckResult): void => {
|
||||
if (result.isLimited) {
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Usage in tRPC routes:
|
||||
|
||||
```typescript
|
||||
const result = await request2FAEmailRateLimit.check({
|
||||
ip: ctx.requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: input.recipientId,
|
||||
});
|
||||
|
||||
assertRateLimit(result);
|
||||
```
|
||||
|
||||
Both helpers live in `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` alongside the Hono middleware.
|
||||
|
||||
### 3.4 Auth endpoint rate limiting
|
||||
|
||||
In `packages/auth/server/routes/email-password.ts`, add rate limit checks at the start of each handler using the `rateLimitResponse` helper.
|
||||
|
||||
Apply to each endpoint per the tier list:
|
||||
|
||||
| Endpoint | Rate Limit |
|
||||
| --------------------------- | ----------------------------------------------------- |
|
||||
| `POST /signup` | `signupRateLimit` with `identifier: email` |
|
||||
| `POST /authorize` (login) | `loginRateLimit` with `identifier: email` |
|
||||
| `POST /forgot-password` | `forgotPasswordRateLimit` with `identifier: email` |
|
||||
| `POST /resend-verify-email` | `resendVerifyEmailRateLimit` with `identifier: email` |
|
||||
| `POST /verify-email` | `verifyEmailRateLimit` with `identifier: token` |
|
||||
| `POST /reset-password` | `resetPasswordRateLimit` with `identifier: token` |
|
||||
| `POST /passkey/authorize` | `passkeyRateLimit` (IP only, no identifier) |
|
||||
| `POST /oauth/authorize/*` | `oauthRateLimit` (IP only) |
|
||||
|
||||
### 3.4 tRPC unauthenticated route rate limiting
|
||||
|
||||
For unauthenticated tRPC routes that send emails, add rate limit checks at the start of the route handler:
|
||||
|
||||
| Route | Rate Limit | Identifier |
|
||||
| ---------------------------------------------------------- | ------------------------------------ | ---------------------- |
|
||||
| `document.accessAuth.request2FAEmail` | `request2FAEmailRateLimit` | `recipientId` or token |
|
||||
| `enterprise.organisation.authenticationPortal.linkAccount` | `linkOrgAccountRateLimit` | email |
|
||||
| `template.createDocumentFromDirectTemplate` | Dedicated direct template rate limit | IP only |
|
||||
|
||||
Access `requestMetadata` from the tRPC context (`ctx.requestMetadata.ipAddress`).
|
||||
|
||||
### 3.5 tRPC and file routes — general API rate limiting
|
||||
|
||||
Add rate limit middleware for currently unprotected routes:
|
||||
|
||||
- `/api/trpc/*` — apply `apiTrpcRateLimit` middleware
|
||||
- `/api/files/*` — apply `fileUploadRateLimit` middleware
|
||||
|
||||
---
|
||||
|
||||
## 4. Cleanup Job
|
||||
|
||||
### 4.1 Job definition
|
||||
|
||||
Create `packages/lib/jobs/definitions/internal/cleanup-rate-limits.ts`:
|
||||
|
||||
```typescript
|
||||
export const CLEANUP_RATE_LIMITS_JOB_DEFINITION = {
|
||||
id: 'internal.cleanup-rate-limits',
|
||||
name: 'Cleanup Rate Limits',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: 'internal.cleanup-rate-limits',
|
||||
schema: z.object({}),
|
||||
cron: '*/15 * * * *', // Every 15 minutes
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./cleanup-rate-limits.handler');
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<...>;
|
||||
```
|
||||
|
||||
### 4.2 Job handler
|
||||
|
||||
Create `packages/lib/jobs/definitions/internal/cleanup-rate-limits.handler.ts`:
|
||||
|
||||
- Delete all `RateLimit` rows where `createdAt` is older than 24 hours (covers all possible windows with margin)
|
||||
- Use batched deletes to avoid long-running transactions
|
||||
- Batch in chunks of 10,000 rows
|
||||
|
||||
```typescript
|
||||
export const run = async () => {
|
||||
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
let deleted = 0;
|
||||
do {
|
||||
// Prisma doesn't support DELETE with LIMIT, so use raw SQL for batching
|
||||
deleted = await prisma.$executeRaw`
|
||||
DELETE FROM "RateLimit"
|
||||
WHERE "createdAt" < ${cutoff}
|
||||
AND ctid IN (
|
||||
SELECT ctid FROM "RateLimit"
|
||||
WHERE "createdAt" < ${cutoff}
|
||||
LIMIT 10000
|
||||
)
|
||||
`;
|
||||
} while (deleted > 0);
|
||||
};
|
||||
```
|
||||
|
||||
### 4.3 Register in job client
|
||||
|
||||
Add `CLEANUP_RATE_LIMITS_JOB_DEFINITION` to the job registry in `packages/lib/jobs/client.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Remove hono-rate-limiter Dependency
|
||||
|
||||
After the migration is complete:
|
||||
|
||||
- Remove `hono-rate-limiter` from `apps/remix/package.json`
|
||||
- Run `npm install` to clean up
|
||||
|
||||
---
|
||||
|
||||
## 6. Files to Create or Modify
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/lib/server-only/rate-limit/rate-limit.ts` | Core rate limit factory (`rateLimit()`) with window parsing, bucket computation, Prisma upsert, fail-open |
|
||||
| `packages/lib/server-only/rate-limit/rate-limits.ts` | All rate limit instances (auth, API, AI, file upload) |
|
||||
| `packages/lib/server-only/rate-limit/rate-limit-middleware.ts` | Hono middleware factory, `rateLimitResponse` helper for Hono handlers, `assertRateLimit` helper for tRPC routes |
|
||||
| `packages/lib/jobs/definitions/internal/cleanup-rate-limits.ts` | Cleanup cron job definition |
|
||||
| `packages/lib/jobs/definitions/internal/cleanup-rate-limits.handler.ts` | Cleanup handler (batched deletes) |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/prisma/schema.prisma` | Add `RateLimit` model |
|
||||
| `apps/remix/server/router.ts` | Replace `hono-rate-limiter` with DB-backed middleware, add rate limits for `/api/trpc/*` and `/api/files/*` |
|
||||
| `apps/remix/package.json` | Remove `hono-rate-limiter` dependency |
|
||||
| `packages/auth/server/routes/email-password.ts` | Add rate limit checks to signup, login, forgot-password, resend-verify-email, verify-email, reset-password |
|
||||
| `packages/auth/server/routes/passkey.ts` | Add rate limit check to passkey authorize |
|
||||
| `packages/auth/server/routes/oauth.ts` | Add rate limit check to OAuth authorize endpoints |
|
||||
| `packages/trpc/server/document-router/access-auth-request-2fa-email.ts` | Add rate limit check (sends email, unauthenticated) |
|
||||
| `packages/trpc/server/enterprise-router/link-organisation-account.ts` | Add rate limit check (sends email, unauthenticated) |
|
||||
| `packages/lib/jobs/client.ts` | Register cleanup-rate-limits job definition |
|
||||
|
||||
---
|
||||
|
||||
## 7. Considerations
|
||||
|
||||
### 7.1 Fail-open
|
||||
|
||||
All rate limit checks must be wrapped in try/catch. On any DB error, log the error and allow the request through. Rate limiting should never block legitimate traffic due to infrastructure issues.
|
||||
|
||||
### 7.2 Performance
|
||||
|
||||
- Each API request adds 1 upsert query (~1ms)
|
||||
- Auth requests add 2 upsert queries (~2ms total)
|
||||
- The composite primary key ensures all lookups and upserts are index-only operations
|
||||
- No `COUNT(*)` queries — the count is stored directly in the row
|
||||
|
||||
### 7.3 Monitoring
|
||||
|
||||
Log rate limit hits at `warn` level with the action, key type (IP/identifier), and count. This provides visibility into traffic patterns and helps tune limits.
|
||||
|
||||
### 7.4 Testing
|
||||
|
||||
The rate limit module should be mockable in tests. Consider exporting the bucket computation and window parsing as standalone functions for unit testing. Integration tests can verify the upsert + count logic against a test database.
|
||||
|
||||
### 7.5 Future improvements
|
||||
|
||||
- **Redis backend**: if DB pressure from rate limiting becomes measurable, swap the Prisma upsert for Redis `INCR` + `EXPIRE` with no API changes
|
||||
- **System-wide circuit breaker**: add a `systemMax` config option that counts all requests for an action regardless of key
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -147,14 +162,25 @@ NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
||||
# We only collect: app version, installation ID, and node ID. No personal data is collected.
|
||||
DOCUMENSO_DISABLE_TELEMETRY=
|
||||
|
||||
# [[AI]]
|
||||
# OPTIONAL: Google Cloud Project ID for Vertex AI.
|
||||
GOOGLE_VERTEX_PROJECT_ID=""
|
||||
# OPTIONAL: Google Cloud region for Vertex AI. Defaults to "global".
|
||||
GOOGLE_VERTEX_LOCATION="global"
|
||||
# OPTIONAL: API key for Google Vertex AI (Gemini). Get your key from:
|
||||
# https://console.cloud.google.com/vertex-ai/studio/settings/api-keys
|
||||
GOOGLE_VERTEX_API_KEY=""
|
||||
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
# OPTIONAL: Set to "true" to disable all rate limiting. Only use for E2E tests.
|
||||
DANGEROUS_BYPASS_RATE_LIMITS=
|
||||
|
||||
# [[LOGGER]]
|
||||
# OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
|
||||
NEXT_PRIVATE_LOGGER_FILE_PATH=
|
||||
|
||||
# [[PLAIN SUPPORT]]
|
||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: ['main', 'feat/rr7']
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e_tests:
|
||||
name: 'E2E Tests'
|
||||
timeout-minutes: 60
|
||||
runs-on: warp-ubuntu-2204-x64-16x
|
||||
runs-on: warp-ubuntu-2204-x64-8x
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -28,9 +33,6 @@ jobs:
|
||||
- name: Seed the database
|
||||
run: npm run prisma:seed
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
|
||||
- name: Install playwright browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
@@ -39,13 +41,14 @@ jobs:
|
||||
env:
|
||||
# Needed since we use next start which will set the NODE_ENV to production
|
||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH: './example/cert.p12'
|
||||
DANGEROUS_BYPASS_RATE_LIMITS: 'true'
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
path: 'packages/app-tests/**/test-results/*'
|
||||
retention-days: 30
|
||||
retention-days: 7
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,6 +17,7 @@ jobs:
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -26,12 +27,54 @@ jobs:
|
||||
- name: Extract translations
|
||||
run: npm run translate:extract
|
||||
|
||||
- name: Check and commit any files created
|
||||
- name: Commit changes and push to reserved branch
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="chore/extract-translations"
|
||||
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@documenso.com'
|
||||
|
||||
git fetch origin
|
||||
|
||||
# Create branch locally (always reset to main)
|
||||
git checkout -B "$BRANCH" origin/main
|
||||
|
||||
# Stage translation output
|
||||
git add packages/lib/translations
|
||||
git diff --staged --quiet --exit-code || (git commit -m "chore: extract translations" && git push)
|
||||
|
||||
# If no changes, exit early
|
||||
if git diff --staged --quiet; then
|
||||
echo "No translation changes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Commit fresh snapshot
|
||||
git commit -m "chore: extract translations"
|
||||
|
||||
# Force push reserved branch
|
||||
git push origin "$BRANCH" --force
|
||||
|
||||
# Does a PR already exist?
|
||||
EXISTING_PR=$(gh pr list \
|
||||
--state open \
|
||||
--head "$BRANCH" \
|
||||
--json number \
|
||||
--jq '.[0].number // empty')
|
||||
|
||||
if [ -z "$EXISTING_PR" ]; then
|
||||
echo "No existing PR — creating new one."
|
||||
gh pr create \
|
||||
--title "chore: extract translations" \
|
||||
--body "Automated translation extraction" \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
else
|
||||
echo "PR #$EXISTING_PR already exists — not creating a new one."
|
||||
fi
|
||||
|
||||
- name: Compile translations
|
||||
id: compile_translations
|
||||
|
||||
@@ -60,3 +60,10 @@ CLAUDE.md
|
||||
|
||||
# agents
|
||||
.specs
|
||||
|
||||
# scripts
|
||||
scripts/output*
|
||||
|
||||
# license
|
||||
.documenso-license.json
|
||||
.documenso-license-backup.json
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
auto-install-peers = true
|
||||
legacy-peer-deps = true
|
||||
prefer-dedupe = true
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -17,5 +17,6 @@
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
},
|
||||
"prisma.pinToPrisma6": true
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
> 🚨 🚨 🚨
|
||||
> Documenso 2.0.0 is live on Product Hunt 🎉 <a href="https://documen.so/launch" target="_blank" rel="noopener noreferrer" style="text-decoration: underline;">Join us to celebrate the best Documenso yet 🪩</a>
|
||||
|
||||
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||
|
||||
<p align="center" style="margin-top: 20px">
|
||||
@@ -174,9 +171,11 @@ git clone https://github.com/<your-username>/documenso
|
||||
|
||||
5. Create the database schema by running `npm run prisma:migrate-dev`
|
||||
|
||||
6. Run `npm run dev` in the root directory to start
|
||||
6. Run `npm run translate:compile` in the root directory to compile lingui
|
||||
|
||||
7. Register a new user at http://localhost:3000/signup
|
||||
7. Run `npm run dev` in the root directory to start
|
||||
|
||||
8. Register a new user at http://localhost:3000/signup
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"next": "^15",
|
||||
"next": "15.5.9",
|
||||
"next-plausible": "^3.12.5",
|
||||
"nextra": "^3",
|
||||
"nextra-theme-docs": "^3",
|
||||
@@ -29,4 +29,4 @@
|
||||
"pagefind": "^1.2.0",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export default {
|
||||
title: 'API & Integration Guides',
|
||||
},
|
||||
'public-api': 'Public API',
|
||||
embedding: 'Embedding',
|
||||
embedding: 'Embedded Signing',
|
||||
'embedded-authoring': 'Embedded Authoring',
|
||||
webhooks: 'Webhooks',
|
||||
};
|
||||
|
||||
@@ -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.
|
||||

|
||||
|
||||

|
||||
```bash
|
||||
# New editor
|
||||
|
||||
https://app.documenso.com/t/<team-url>/documents/<envelope-id>/edit?step=addFields&devmode=true
|
||||
```
|
||||
|
||||

|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
---
|
||||
title: Embedded Authoring
|
||||
description: Learn how to use embedded authoring to create documents and templates in your application
|
||||
---
|
||||
|
||||
import { Callout } from 'nextra/components';
|
||||
|
||||
<Callout type="info">
|
||||
The embedded authoring feature is an enterprise only feature. Please contact us if you are
|
||||
interested in using it.
|
||||
</Callout>
|
||||
|
||||
# Embedded Authoring
|
||||
|
||||
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation and editing directly within your application.
|
||||
|
||||
## Embedded Signing vs Embedded Authoring
|
||||
|
||||
Embedded signing allows you to embed your Documenso documents into your application for signing. Your users will be able to sign the document directly in your application.
|
||||
|
||||
Embedded authoring allows you to integrate Documenso's document and template creation and editing into your application. You will be able to create and edit documents and templates directly in your application.
|
||||
|
||||
## How Embedded Authoring Works
|
||||
|
||||
The embedded authoring feature enables your users to create and edit documents and templates without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
|
||||
|
||||
## Available Components
|
||||
|
||||
The SDK provides four authoring components:
|
||||
|
||||
- **`EmbedCreateDocumentV1`** - Create new documents
|
||||
- **`EmbedCreateTemplateV1`** - Create new templates
|
||||
- **`EmbedUpdateDocumentV1`** - Edit existing documents
|
||||
- **`EmbedUpdateTemplateV1`** - Edit existing templates
|
||||
|
||||
React Example:
|
||||
|
||||
```jsx
|
||||
import {
|
||||
EmbedCreateDocumentV1,
|
||||
EmbedCreateTemplateV1,
|
||||
EmbedUpdateDocumentV1,
|
||||
EmbedUpdateTemplateV1,
|
||||
} from '@documenso/embed-react';
|
||||
```
|
||||
|
||||
## Creating Documents
|
||||
|
||||
To implement document creation in your application, use the `EmbedCreateDocumentV1` component:
|
||||
|
||||
```jsx
|
||||
import { EmbedCreateDocumentV1 } from '@documenso/embed-react';
|
||||
|
||||
const DocumentCreator = () => {
|
||||
// You'll need to obtain a presign token using your API key
|
||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedCreateDocument
|
||||
presignToken={presignToken}
|
||||
externalId="order-12345"
|
||||
onDocumentCreated={(data) => {
|
||||
console.log('Document created with ID:', data.documentId);
|
||||
console.log('External reference ID:', data.externalId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Creating Templates
|
||||
|
||||
To create templates, use the `EmbedCreateTemplateV1` component:
|
||||
|
||||
```jsx
|
||||
import { EmbedCreateTemplateV1 } from '@documenso/embed-react';
|
||||
|
||||
const TemplateCreator = () => {
|
||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedCreateTemplate
|
||||
presignToken={presignToken}
|
||||
externalId="template-12345"
|
||||
onTemplateCreated={(data) => {
|
||||
console.log('Template created with ID:', data.templateId);
|
||||
console.log('External reference ID:', data.externalId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Updating Documents
|
||||
|
||||
To edit existing documents, use the `EmbedUpdateDocumentV1` component:
|
||||
|
||||
```jsx
|
||||
import { EmbedUpdateDocumentV1 } from '@documenso/embed-react';
|
||||
|
||||
const DocumentEditor = () => {
|
||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||
const documentId = 123; // The ID of the document to edit
|
||||
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedUpdateDocument
|
||||
presignToken={presignToken}
|
||||
documentId={documentId}
|
||||
externalId="order-12345"
|
||||
onlyEditFields={false}
|
||||
onDocumentUpdated={(data) => {
|
||||
console.log('Document updated:', data.documentId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Updating Templates
|
||||
|
||||
To edit existing templates, use the `EmbedUpdateTemplateV1` component:
|
||||
|
||||
```jsx
|
||||
import { EmbedUpdateTemplateV1 } from '@documenso/embed-react';
|
||||
|
||||
const TemplateEditor = () => {
|
||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||
const templateId = 456; // The ID of the template to edit
|
||||
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedUpdateTemplate
|
||||
presignToken={presignToken}
|
||||
templateId={templateId}
|
||||
externalId="template-12345"
|
||||
onlyEditFields={false}
|
||||
onTemplateUpdated={(data) => {
|
||||
console.log('Template updated:', data.templateId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Obtaining a Presign Token
|
||||
|
||||
Before using any of the authoring components, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
|
||||
|
||||
You can create a presign token by making a request to:
|
||||
|
||||
```
|
||||
POST /api/v2/embedding/create-presign-token
|
||||
```
|
||||
|
||||
This API endpoint requires authentication with your Documenso API key. The token has a default expiration of 1 hour, but you can customize this duration based on your security requirements.
|
||||
|
||||
You can find more details on this request at our [API Documentation](https://openapi.documenso.com/reference#tag/embedding)
|
||||
|
||||
## Configuration Options
|
||||
|
||||
All authoring components accept the following configuration options:
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------------------ | ------- | -------------------------------------------------------------------------- |
|
||||
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
|
||||
| `externalId` | string | Optional reference ID from your system to link with the document/template. |
|
||||
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
|
||||
| `css` | string | Optional custom CSS to style the embedded component. |
|
||||
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
|
||||
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
|
||||
| `className` | string | Optional CSS class name for the iframe. |
|
||||
| `additionalProps` | object | Optional additional props to pass to the iframe (for testing features). |
|
||||
| `features` | object | Optional feature toggles to customize the authoring experience. |
|
||||
|
||||
### Update Component Specific Props
|
||||
|
||||
The `EmbedUpdateDocument` and `EmbedUpdateTemplate` components also accept:
|
||||
|
||||
| Option | Type | Description |
|
||||
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `documentId` | number | **Required for EmbedUpdateDocument**. The ID of the document to edit. |
|
||||
| `templateId` | number | **Required for EmbedUpdateTemplate**. The ID of the template to edit. |
|
||||
| `onlyEditFields` | boolean | Optional flag to restrict editing to fields only skipping the recipient configuration step (default: `false`). |
|
||||
|
||||
## Feature Toggles
|
||||
|
||||
You can customize the authoring experience by enabling or disabling specific features:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocument
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
features={{
|
||||
allowConfigureSignatureTypes: true,
|
||||
allowConfigureLanguage: true,
|
||||
allowConfigureDateFormat: true,
|
||||
allowConfigureTimezone: true,
|
||||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureCommunication: true,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Handling Events
|
||||
|
||||
Each component provides callbacks for handling completion events:
|
||||
|
||||
### Document Events
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocument
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
externalId="order-12345"
|
||||
onDocumentCreated={(data) => {
|
||||
// Navigate to a success page
|
||||
navigate(`/documents/success?id=${data.documentId}`);
|
||||
|
||||
// Or update your database with the document ID
|
||||
updateOrderDocument(data.externalId, data.documentId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EmbedUpdateDocument
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
documentId={123}
|
||||
onDocumentUpdated={(data) => {
|
||||
console.log('Document updated:', data.documentId);
|
||||
// Handle document update
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Template Events
|
||||
|
||||
```jsx
|
||||
<EmbedCreateTemplate
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
externalId="template-12345"
|
||||
onTemplateCreated={(data) => {
|
||||
console.log('Template created:', data.templateId);
|
||||
// Handle template creation
|
||||
}}
|
||||
/>
|
||||
|
||||
<EmbedUpdateTemplate
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
templateId={456}
|
||||
onTemplateUpdated={(data) => {
|
||||
console.log('Template updated:', data.templateId);
|
||||
// Handle template update
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
All event callbacks receive an object with:
|
||||
|
||||
- `documentId` or `templateId` - The ID of the created/updated document or template
|
||||
- `externalId` - Your external reference ID (if provided)
|
||||
|
||||
## Styling the Embedded Component
|
||||
|
||||
You can customize the appearance of the embedded component using standard CSS classes, custom CSS, and CSS variables:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocument
|
||||
className="h-screen w-full rounded-lg border-none shadow-md"
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
css={`
|
||||
.documenso-embed {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
cssVars={{
|
||||
primary: '#0000FF',
|
||||
background: '#F5F5F5',
|
||||
radius: '8px',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Complete Integration Example
|
||||
|
||||
Here's a complete example of integrating document creation in a React application:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
|
||||
import { EmbedCreateDocumentV1, EmbedUpdateDocumentV1 } from '@documenso/embed-react';
|
||||
|
||||
function DocumentManager() {
|
||||
// In a real application, you would fetch this token from your backend
|
||||
// using your API key at /api/v2/embedding/create-presign-token
|
||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||
const [documentId, setDocumentId] = useState<number | null>(null);
|
||||
const [mode, setMode] = useState<'create' | 'edit'>('create');
|
||||
|
||||
if (documentId && mode === 'create') {
|
||||
return (
|
||||
<div>
|
||||
<h2>Document Created Successfully!</h2>
|
||||
<p>Document ID: {documentId}</p>
|
||||
<div>
|
||||
<button onClick={() => setMode('edit')}>Edit Document</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDocumentId(null);
|
||||
setMode('create');
|
||||
}}
|
||||
>
|
||||
Create Another Document
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'edit' && documentId) {
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<button onClick={() => setMode('create')}>Back to Create</button>
|
||||
<EmbedUpdateDocument
|
||||
presignToken={presignToken}
|
||||
documentId={documentId}
|
||||
externalId="order-12345"
|
||||
onDocumentUpdated={(data) => {
|
||||
console.log('Document updated:', data.documentId);
|
||||
setMode('create');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedCreateDocument
|
||||
presignToken={presignToken}
|
||||
externalId="order-12345"
|
||||
features={{
|
||||
allowConfigureSignatureTypes: true,
|
||||
allowConfigureLanguage: true,
|
||||
allowConfigureDateFormat: true,
|
||||
allowConfigureTimezone: true,
|
||||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureCommunication: true,
|
||||
}}
|
||||
onDocumentCreated={(data) => {
|
||||
setDocumentId(data.documentId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentManager;
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Using Additional Props
|
||||
|
||||
You can pass additional props to the iframe for testing features before they're officially supported:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocument
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
additionalProps={{
|
||||
experimentalFeature: true,
|
||||
customSetting: 'value',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Restricting To Only Field Editing
|
||||
|
||||
When updating documents or templates, you can restrict editing to fields only skipping the recipient configuration step:
|
||||
|
||||
```jsx
|
||||
<EmbedUpdateDocument
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
documentId={123}
|
||||
onlyEditFields={true}
|
||||
onDocumentUpdated={(data) => {
|
||||
console.log('Fields updated:', data.documentId);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
With embedded authoring, your users can seamlessly create and edit documents and templates within your application, enhancing the overall user experience and streamlining document workflows.
|
||||
@@ -7,5 +7,4 @@ export default {
|
||||
preact: 'Preact Integration',
|
||||
angular: 'Angular Integration',
|
||||
'css-variables': 'CSS Variables',
|
||||
authoring: 'Authoring',
|
||||
};
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
---
|
||||
title: Authoring
|
||||
description: Learn how to use embedded authoring to create documents and templates in your application
|
||||
---
|
||||
|
||||
# Embedded Authoring
|
||||
|
||||
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation directly within your application.
|
||||
|
||||
## How Embedded Authoring Works
|
||||
|
||||
The embedded authoring feature enables your users to create new documents without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
|
||||
|
||||
## Creating Documents with Embedded Authoring
|
||||
|
||||
To implement document creation in your application, use the `EmbedCreateDocument` component from our SDK:
|
||||
|
||||
```jsx
|
||||
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
|
||||
|
||||
const DocumentCreator = () => {
|
||||
// You'll need to obtain a presign token using your API key
|
||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedCreateDocument
|
||||
presignToken={presignToken}
|
||||
externalId="order-12345"
|
||||
onDocumentCreated={(data) => {
|
||||
console.log('Document created with ID:', data.documentId);
|
||||
console.log('External reference ID:', data.externalId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Obtaining a Presign Token
|
||||
|
||||
Before using the `EmbedCreateDocument` component, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
|
||||
|
||||
You can create a presign token by making a request to:
|
||||
|
||||
```
|
||||
POST /api/v2/embedding/create-presign-token
|
||||
```
|
||||
|
||||
This API endpoint requires authentication with your Documenso API key. The token has a default expiration of 1 hour, but you can customize this duration based on your security requirements.
|
||||
|
||||
You can find more details on this request at our [API Documentation](https://openapi.documenso.com/reference#tag/embedding)
|
||||
|
||||
## Configuration Options
|
||||
|
||||
The `EmbedCreateDocument` component accepts several configuration options:
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------------------ | ------- | ------------------------------------------------------------------ |
|
||||
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
|
||||
| `externalId` | string | Optional reference ID from your system to link with the document. |
|
||||
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
|
||||
| `css` | string | Optional custom CSS to style the embedded component. |
|
||||
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
|
||||
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
|
||||
| `className` | string | Optional CSS class name for the iframe. |
|
||||
|
||||
## Feature Toggles
|
||||
|
||||
You can customize the authoring experience by enabling or disabling specific features:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocument
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
features={{
|
||||
allowConfigureSignatureTypes: true,
|
||||
allowConfigureLanguage: true,
|
||||
allowConfigureDateFormat: true,
|
||||
allowConfigureTimezone: true,
|
||||
allowConfigureRedirectUrl: true,
|
||||
allowConfigureCommunication: true,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Handling Document Creation Events
|
||||
|
||||
The `onDocumentCreated` callback is triggered when a document is successfully created, providing both the document ID and your external reference ID:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocument
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
externalId="order-12345"
|
||||
onDocumentCreated={(data) => {
|
||||
// Navigate to a success page
|
||||
navigate(`/documents/success?id=${data.documentId}`);
|
||||
|
||||
// Or update your database with the document ID
|
||||
updateOrderDocument(data.externalId, data.documentId);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Styling the Embedded Component
|
||||
|
||||
You can customize the appearance of the embedded component using standard CSS classes:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocument
|
||||
className="h-screen w-full rounded-lg border-none shadow-md"
|
||||
presignToken="YOUR_PRESIGN_TOKEN"
|
||||
css={`
|
||||
.documenso-embed {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
cssVars={{
|
||||
primary: '#0000FF',
|
||||
background: '#F5F5F5',
|
||||
radius: '8px',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Complete Integration Example
|
||||
|
||||
Here's a complete example of integrating document creation in a React application:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
|
||||
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
|
||||
|
||||
function DocumentCreator() {
|
||||
// In a real application, you would fetch this token from your backend
|
||||
// using your API key at /api/v2/embedding/create-presign-token
|
||||
const presignToken = 'YOUR_PRESIGN_TOKEN';
|
||||
const [documentId, setDocumentId] = useState<number | null>(null);
|
||||
|
||||
if (documentId) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Document Created Successfully!</h2>
|
||||
<p>Document ID: {documentId}</p>
|
||||
<button onClick={() => setDocumentId(null)}>Create Another Document</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '800px', width: '100%' }}>
|
||||
<EmbedCreateDocument
|
||||
presignToken={presignToken}
|
||||
externalId="order-12345"
|
||||
onDocumentCreated={(data) => {
|
||||
setDocumentId(data.documentId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentCreator;
|
||||
```
|
||||
|
||||
With embedded authoring, your users can seamlessly create documents within your application, enhancing the overall user experience and streamlining document workflows.
|
||||
@@ -3,10 +3,16 @@ title: Get Started
|
||||
description: Learn how to use embedding to bring signing to your own website or application
|
||||
---
|
||||
|
||||
# Embedding
|
||||
# Embedded Signing
|
||||
|
||||
Our embedding feature lets you integrate our document signing experience into your own application or website. Whether you're building with React, Preact, Vue, Svelte, Solid, Angular, or using generalized web components, this guide will help you get started with embedding Documenso.
|
||||
|
||||
## Embedded Signing vs Embedded Authoring
|
||||
|
||||
Embedded signing allows you to embed your Documenso documents into your application for signing. Your users will be able to sign the document directly in your application.
|
||||
|
||||
Embedded authoring allows you to integrate Documenso's document and template creation and editing into your application. You will be able to create and edit documents and templates directly in your application.
|
||||
|
||||
## Availability
|
||||
|
||||
Embedding is currently available for all users on a **Teams Plan** and above, as well as **Early Adopter's** within a team (Early Adopters can create a team for free).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,4 +4,5 @@ export default {
|
||||
'how-to': 'How To',
|
||||
'setting-up-oauth-providers': 'Setting up OAuth Providers',
|
||||
telemetry: 'Telemetry',
|
||||
'ai-features': 'AI Recipient & Field Detection',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: AI Recipient & Field Detection (Self-hosting)
|
||||
description: Configure Google Vertex AI so Documenso can detect recipients and fields automatically.
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# AI Recipient & Field Detection (Self-hosting)
|
||||
|
||||
This guide covers how to enable the AI recipient and field detection features when you self-host Documenso.
|
||||
|
||||
## What this enables
|
||||
|
||||
- Detect recipients from uploaded PDFs (roles, names, emails when present).
|
||||
- Detect and place fields (signature, initials, name, email, date, text, number, radio, checkbox) onto draft envelopes.
|
||||
- Built-in rate limits (3 requests per minute per IP) to prevent abuse.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Google Cloud project with the **Vertex AI API** enabled and billing active.
|
||||
- A **Vertex AI Express API key** with access to Gemini models (create via the [Vertex AI Express flow](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview) and manage keys in [API keys](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys)).
|
||||
- Documenso version that includes the AI detection feature and the corresponding database migration.
|
||||
|
||||
## Configure environment variables
|
||||
|
||||
Add these variables to your deployment `.env` (or secret manager):
|
||||
|
||||
```
|
||||
GOOGLE_VERTEX_PROJECT_ID="<your-gcp-project-id>"
|
||||
GOOGLE_VERTEX_API_KEY="<your-vertex-api-key>"
|
||||
# Optional, defaults to "global"
|
||||
GOOGLE_VERTEX_LOCATION="global"
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Use a region close to your users if you need data residency considerations (e.g. `europe-west1`).
|
||||
If you omit the location, Documenso uses `global`. Not all models are available in every region;
|
||||
if a model is unavailable, switch to a supported region.
|
||||
</Callout>
|
||||
|
||||
## Deploy with the published container
|
||||
|
||||
- Use the official Documenso image (DockerHub or GHCR) and supply the Vertex env vars above.
|
||||
- Ensure migrations run on startup (the container runs `prisma migrate deploy` in production mode).
|
||||
- Restart the container after adding or changing Vertex env vars.
|
||||
|
||||
## Enable the feature in Documenso
|
||||
|
||||
Once the service is running with the Vertex env vars:
|
||||
|
||||
<Steps>
|
||||
### Organisation settings
|
||||
|
||||
Go to **Settings → Document Preferences → AI Features** and set to **Enabled**. Teams that inherit organisation defaults will pick this up.
|
||||
|
||||
### Team settings
|
||||
|
||||
If a team overrides organisation defaults, go to **Team Settings → Document Preferences → AI Features** and choose **Enabled** (or **Inherit** to follow the organisation).
|
||||
|
||||
### Verify in the editor
|
||||
|
||||
Open a draft envelope. In **Recipients**, you should see the sparkle button for AI detection. In **Fields**, you should see **Detect with AI** available.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Too many requests**: Wait a minute or two and retry (rate limit is 3/min per IP).
|
||||
- **AI options hidden**: Ensure the env vars are set, the server was restarted after setting them, and `aiFeaturesEnabled` is enabled at organisation/team level.
|
||||
- **Detection fails immediately**: Confirm the Vertex API key is valid and the project has Vertex AI enabled. Check server logs for status codes from Vertex.
|
||||
|
||||
If issues persist, recheck env vars, restart the service, and confirm the Prisma migration was applied.
|
||||
@@ -119,6 +119,8 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
||||
```
|
||||
|
||||
For full AI setup details (including model availability notes), see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
|
||||
|
||||
### Set Up Your Signing Certificate
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -146,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 \
|
||||
@@ -267,58 +270,66 @@ You can access the Documenso application by visiting the URL you provided for th
|
||||
|
||||
The environment variables listed above are a subset of those available for configuring Documenso. The table below provides a complete list of environment variables and their descriptions.
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
||||
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
|
||||
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
||||
| `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_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_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. |
|
||||
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
||||
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
|
||||
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
|
||||
For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
||||
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
|
||||
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
||||
| `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), 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. |
|
||||
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
||||
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
|
||||
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
|
||||
| `GOOGLE_VERTEX_PROJECT_ID` | Google Cloud project ID used for Vertex AI (required for AI detection). |
|
||||
| `GOOGLE_VERTEX_API_KEY` | Vertex AI Express API key with access to Gemini models (required for AI detection). See [AI Recipient & Field Detectionfor](./ai-features) for details. |
|
||||
| `GOOGLE_VERTEX_LOCATION` | Optional Vertex region, defaults to `global`. Not all models are available in every region. |
|
||||
|
||||
## Telemetry
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

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

|
||||

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

|
||||

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

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

|
||||
|
||||
You can go even further and check the execution details of each call by clicking on a specific webhook call.
|
||||
|
||||

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

|
||||

|
||||
|
||||
This opens a dialog where you can select the event type to test.
|
||||
|
||||

|
||||

|
||||
|
||||
Choose the appropriate event and click "Send Test Webhook." You’ll shortly receive a test payload from Documenso with sample data.
|
||||
Choose the event you want to test and click "Send". You’ll 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.
|
||||
|
||||

|
||||
|
||||
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,5 +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,68 @@
|
||||
---
|
||||
title: AI Recipient & Field Detection
|
||||
description: Use Documenso’s AI helpers to detect recipients and fields in draft documents.
|
||||
---
|
||||
|
||||
# AI Recipient & Field Detection
|
||||
|
||||
Documenso can suggest recipients and place fields automatically using Google Vertex AI (Gemini). The feature is optional and only available when your organisation or team has **AI Features** enabled. Documents are processed securely and providers do not retain your data for training.
|
||||
|
||||
## Requirements
|
||||
|
||||
- AI Features must be enabled in **Document Preferences** for your organisation or team.
|
||||
- The envelope must be in **Draft** status.
|
||||
- Helpful rate limits are in place (up to 3 detection requests per minute per IP) to prevent abuse. If you see a “too many requests” message, wait a minute or two and try again.
|
||||
|
||||
### Enable AI features
|
||||
|
||||
1. **Organisation settings**:
|
||||
|
||||
Settings → Document Preferences → **AI Features** → Enabled.
|
||||
|
||||
_This applies to teams that inherit organisation defaults._
|
||||
|
||||
2. **Team settings**:
|
||||
|
||||
Team Settings → Document Preferences → **AI Features** → choose Enabled, Disabled, or Inherit.
|
||||
|
||||
## Detect recipients
|
||||
|
||||
Use this to identify who needs to sign or approve.
|
||||
|
||||
1. Open a draft document/template and go to the **Recipients** panel.
|
||||
2. Select the **sparkle** button to start detection. If AI is enabled, uploads launched from the dashboard will open the detector automatically.
|
||||
|
||||

|
||||
|
||||
3. Wait for progress to finish, then review the suggested recipients.
|
||||
4. Remove any incorrect entries, then **Add recipients** to apply them. Existing recipients and duplicates are preserved.
|
||||
|
||||
Notes:
|
||||
|
||||
- Detection is unavailable once an envelope is completed.
|
||||
- You can re-run detection if you update the document; each run counts toward the rate limit.
|
||||
|
||||
## Detect fields
|
||||
|
||||
Use this to auto-place fields on the pages of a draft.
|
||||
|
||||
1. Open the envelope editor and switch to the **Fields** tab.
|
||||
2. Select **Detect with AI**. Provide optional context (e.g., “Alice is the tenant, Bob is the landlord”) to improve recipient assignment.
|
||||
|
||||

|
||||

|
||||
|
||||
3. Watch the progress indicators; they update per page and total fields found.
|
||||
4. Review the summary and choose **Add fields** to place them in the editor.
|
||||
|
||||
Notes:
|
||||
|
||||
- Works only for draft envelopes and teams with AI features enabled.
|
||||
- Existing fields are masked during detection to avoid duplicates.
|
||||
- Fields are assigned to recipients based on nearby labels and your context message; you can edit them after adding.
|
||||
|
||||
## Best practices
|
||||
|
||||
- Keep labels near the intended fields (e.g., “Tenant signature”, “Buyer email”).
|
||||
- Provide short context when roles are ambiguous.
|
||||
- Always review suggestions before sending; AI assists but does not replace final checks.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Default Document Recipients
|
||||
description: Learn how to set default recipients with various roles for your documents.
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Default Document Recipients
|
||||
|
||||
Documenso allows you to set default recipients for your documents. This is useful when you require specific recipients to be added to every document you send.
|
||||
|
||||
You can add default recipients with the same roles as the recipients you can add when sending a document:
|
||||
|
||||
- **Signer** - The recipient will be required to sign the document.
|
||||
- **Approver** - The recipient will be required to approve the document.
|
||||
- **Viewer** - The recipient will be required to view the document.
|
||||
- **CC** - The recipient will receive a copy of the document.
|
||||
|
||||
You can set default recipients at the organisation or team level.
|
||||
|
||||
### Organisation level
|
||||
|
||||
To set default recipients at the organisation level, navigate to the organisation settings page and click the "Document" tab under the "Preferences" section.
|
||||
|
||||
Then scroll down to the "Default Recipients" section and add the recipients you want to be included in every document you send.
|
||||
|
||||

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

|
||||
|
||||
### Team level
|
||||
|
||||
Setting the default recipients at the team level follows the same process as setting them at the organisation level.
|
||||
|
||||
<Callout type="info">
|
||||
Setting the default recipients at the team level will override organisation-level defaults.
|
||||
</Callout>
|
||||
|
||||
To set default recipients at the team level, navigate to the team settings page and click the "Document" tab under the "Preferences" section.
|
||||
|
||||
Then scroll down to the "Default Recipients" section. By default, the team will inherit the default recipients from the organisation. You can override these defaults by adding the recipients you want to be added to every document you send.
|
||||
|
||||

|
||||
@@ -178,7 +178,7 @@ The dropdown/select field collects a single choice from a list of options.
|
||||
|
||||
Place the dropdown/select field on the document where you want the signer to select a choice. The dropdown/select field comes with additional settings that can be configured.
|
||||
|
||||
{/*  */}
|
||||

|
||||
|
||||
The dropdown/select field settings include:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 won’t 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.
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 555 KiB |
|
After Width: | Height: | Size: 928 KiB |
|
After Width: | Height: | Size: 897 KiB |
|
After Width: | Height: | Size: 596 KiB |
|
After Width: | Height: | Size: 571 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 590 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 49 KiB |
@@ -12,11 +12,11 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "^15"
|
||||
"next": "15.5.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "18.3.27",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
||||
|
||||
toast({
|
||||
title: _(msg`Document deleted`),
|
||||
description: 'The Document has been deleted successfully.',
|
||||
description: _(msg`The Document has been deleted successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -54,8 +54,9 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to delete your document. Please try again later.',
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to delete your document. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,8 +156,8 @@ export const AdminOrganisationMemberUpdateDialog = ({
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are currently updating{' '}
|
||||
<span className="font-bold">{organisationMemberName}.</span>
|
||||
You are currently updating <span className="font-bold">{organisationMemberName}</span>
|
||||
.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type AiFeaturesEnableDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onEnabled: () => void;
|
||||
};
|
||||
|
||||
export const AiFeaturesEnableDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onEnabled,
|
||||
}: AiFeaturesEnableDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isTeamAdmin = team.currentTeamRole === TeamMemberRole.ADMIN;
|
||||
const isOrganisationAdmin = organisation.currentOrganisationRole === OrganisationMemberRole.ADMIN;
|
||||
const canEnableAiFeatures = isTeamAdmin || isOrganisationAdmin;
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: updateTeamSettings, isPending: isUpdatingTeamSettings } =
|
||||
trpc.team.settings.update.useMutation();
|
||||
const { mutateAsync: updateOrganisationSettings, isPending: isUpdatingOrganisationSettings } =
|
||||
trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const isSubmitting = isUpdatingTeamSettings || isUpdatingOrganisationSettings;
|
||||
|
||||
const onEnableClick = async () => {
|
||||
if (!canEnableAiFeatures) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (isTeamAdmin) {
|
||||
await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: { aiFeaturesEnabled: true },
|
||||
});
|
||||
} else {
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: { aiFeaturesEnabled: true },
|
||||
});
|
||||
}
|
||||
|
||||
onEnabled();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to enable AI features', err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t`We couldn't enable AI features right now. Please try again.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Enable AI features</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Turn on AI detection to automatically find recipients and fields in your documents. AI
|
||||
providers do not retain your data for training.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Your document content will be sent securely to our AI provider solely for detection
|
||||
and will not be stored or used for training.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{canEnableAiFeatures ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You're an admin. You can enable AI features for this team right away. Everyone on
|
||||
the team will see AI detection once enabled.
|
||||
</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
AI features are disabled for your team. Please ask your team owner or organisation
|
||||
owner to enable them.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
{canEnableAiFeatures ? (
|
||||
<Button type="button" onClick={() => void onEnableClick()} loading={isSubmitting}>
|
||||
<Trans>Enable AI features</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
|
||||
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
type DetectFieldsProgressEvent,
|
||||
detectFields,
|
||||
} from '../../../server/api/ai/detect-fields.client';
|
||||
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
|
||||
|
||||
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
|
||||
|
||||
type AiFieldDetectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onComplete: (fields: NormalizedFieldWithContext[]) => void;
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const PROCESSING_MESSAGES = [
|
||||
msg`Reading your document`,
|
||||
msg`Analyzing page layout`,
|
||||
msg`Looking for form fields`,
|
||||
msg`Detecting signature areas`,
|
||||
msg`Identifying input fields`,
|
||||
msg`Mapping fields to recipients`,
|
||||
msg`Almost done`,
|
||||
] as const;
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<string, MessageDescriptor> = {
|
||||
SIGNATURE: msg`Signature`,
|
||||
INITIALS: msg`Initials`,
|
||||
NAME: msg`Name`,
|
||||
EMAIL: msg`Email`,
|
||||
DATE: msg`Date`,
|
||||
TEXT: msg`Text`,
|
||||
NUMBER: msg`Number`,
|
||||
CHECKBOX: msg`Checkbox`,
|
||||
RADIO: msg`Radio`,
|
||||
};
|
||||
|
||||
export const AiFieldDetectionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onComplete,
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: AiFieldDetectionDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [state, setState] = useState<DialogState>('PROMPT');
|
||||
const [messageIndex, setMessageIndex] = useState(0);
|
||||
const [detectedFields, setDetectedFields] = useState<NormalizedFieldWithContext[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [context, setContext] = useState('');
|
||||
const [progress, setProgress] = useState<DetectFieldsProgressEvent | null>(null);
|
||||
|
||||
const onDetectClick = useCallback(async () => {
|
||||
setState('PROCESSING');
|
||||
setMessageIndex(0);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
await detectFields({
|
||||
request: {
|
||||
envelopeId,
|
||||
teamId,
|
||||
context: context || undefined,
|
||||
},
|
||||
onProgress: (progressEvent) => {
|
||||
setProgress(progressEvent);
|
||||
},
|
||||
onComplete: (event) => {
|
||||
setDetectedFields(event.fields);
|
||||
setState('REVIEW');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err instanceof AiApiError && err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect fields');
|
||||
setState('ERROR');
|
||||
}
|
||||
}, [envelopeId, teamId, context]);
|
||||
|
||||
const onAddFields = () => {
|
||||
onComplete(detectedFields);
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedFields([]);
|
||||
setContext('');
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedFields([]);
|
||||
setError(null);
|
||||
setContext('');
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
// Group fields by type for summary display
|
||||
const fieldCountsByType = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const field of detectedFields) {
|
||||
counts[field.type] = (counts[field.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return Object.entries(counts).sort(([, a], [, b]) => b - a);
|
||||
}, [detectedFields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-lg" hideClose={true}>
|
||||
{state === 'PROMPT' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detect fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We'll scan your document to find form fields like signature lines, text inputs,
|
||||
checkboxes, and more. Detected fields will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="context">
|
||||
<Trans>Context</Trans>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="context"
|
||||
placeholder={_(msg`David is the Employee, Lucas is the Manager`)}
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Help the AI assign fields to the right recipients.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Detect</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'PROCESSING' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detecting fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<AnimatedDocumentScanner />
|
||||
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Plural
|
||||
value={progress.fieldsDetected}
|
||||
one={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # field found
|
||||
</Trans>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # fields found
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-1">
|
||||
{PROCESSING_MESSAGES.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
|
||||
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'REVIEW' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detected fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{detectedFields.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<FormInputIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No fields were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<Trans>You can add fields manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Plural
|
||||
value={detectedFields.length}
|
||||
one="We found # field in your document."
|
||||
other="We found # fields in your document."
|
||||
/>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
{fieldCountsByType.map(([type, count]) => (
|
||||
<li key={type} className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">{_(FIELD_TYPE_LABELS[type]) || type}</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{detectedFields.length > 0 && (
|
||||
<Button type="button" onClick={onAddFields}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Add fields</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detection failed</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Something went wrong while detecting fields.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'RATE_LIMITED' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Too many requests</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
type DetectRecipientsProgressEvent,
|
||||
detectRecipients,
|
||||
} from '../../../server/api/ai/detect-recipients.client';
|
||||
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
|
||||
|
||||
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
|
||||
|
||||
type AiRecipientDetectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onComplete: (recipients: TDetectedRecipientSchema[]) => void;
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const PROCESSING_MESSAGES = [
|
||||
msg`Reading your document`,
|
||||
msg`Analyzing pages`,
|
||||
msg`Looking for signature fields`,
|
||||
msg`Identifying recipients`,
|
||||
msg`Extracting contact details`,
|
||||
msg`Almost done`,
|
||||
] as const;
|
||||
|
||||
export const AiRecipientDetectionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onComplete,
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: AiRecipientDetectionDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [state, setState] = useState<DialogState>('PROMPT');
|
||||
const [messageIndex, setMessageIndex] = useState(0);
|
||||
const [detectedRecipients, setDetectedRecipients] = useState<TDetectedRecipientSchema[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<DetectRecipientsProgressEvent | null>(null);
|
||||
|
||||
const onDetectClick = useCallback(async () => {
|
||||
setState('PROCESSING');
|
||||
setMessageIndex(0);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
await detectRecipients({
|
||||
request: {
|
||||
envelopeId,
|
||||
teamId,
|
||||
},
|
||||
onProgress: (progressEvent) => {
|
||||
setProgress(progressEvent);
|
||||
},
|
||||
onComplete: (event) => {
|
||||
setDetectedRecipients(event.recipients);
|
||||
setState('REVIEW');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err instanceof AiApiError && err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect recipients');
|
||||
setState('ERROR');
|
||||
}
|
||||
}, [envelopeId, teamId]);
|
||||
|
||||
const handleRemoveRecipient = (index: number) => {
|
||||
setDetectedRecipients((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const onAddRecipients = () => {
|
||||
onComplete(detectedRecipients);
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedRecipients([]);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedRecipients([]);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-lg" hideClose={true}>
|
||||
{state === 'PROMPT' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detect recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We'll scan your document to find signature fields and identify who needs to sign.
|
||||
Detected recipients will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Detect</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'PROCESSING' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detecting recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<AnimatedDocumentScanner />
|
||||
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Plural
|
||||
value={progress.recipientsDetected}
|
||||
one={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # recipient found
|
||||
</Trans>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # recipients found
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-1">
|
||||
{PROCESSING_MESSAGES.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
|
||||
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'REVIEW' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detected recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{detectedRecipients.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<UserIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No recipients were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<Trans>You can add recipients manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Plural
|
||||
value={detectedRecipients.length}
|
||||
one="We found # recipient in your document."
|
||||
other="We found # recipients in your document."
|
||||
/>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
{detectedRecipients.map((recipient, index) => (
|
||||
<li key={index} className="flex items-center justify-between px-4 py-3">
|
||||
<AvatarWithText
|
||||
avatarFallback={
|
||||
recipient.name
|
||||
? recipient.name.slice(0, 1).toUpperCase()
|
||||
: recipient.email
|
||||
? recipient.email.slice(0, 1).toUpperCase()
|
||||
: '?'
|
||||
}
|
||||
primaryText={
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{recipient.name || _(msg`Unknown name`)}
|
||||
</p>
|
||||
}
|
||||
secondaryText={
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="italic text-muted-foreground/70">
|
||||
{recipient.email || _(msg`No email detected`)}
|
||||
</p>
|
||||
<p>{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 p-0 text-muted-foreground/80 hover:text-destructive focus-visible:border-destructive focus-visible:ring-destructive"
|
||||
onClick={() => handleRemoveRecipient(index)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
<Trans>Remove recipient</Trans>
|
||||
</span>
|
||||
|
||||
<XIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{detectedRecipients.length > 0 && (
|
||||
<Button type="button" onClick={onAddRecipients}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Add recipients</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detection failed</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Something went wrong while detecting recipients.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'RATE_LIMITED' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Too many requests</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -116,9 +116,11 @@ export function AssistantConfirmationDialog({
|
||||
{!isEditingNextSigner && (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The next recipient to sign this document will be{' '}
|
||||
<span className="font-semibold">{form.watch('name')}</span> (
|
||||
<span className="font-semibold">{form.watch('email')}</span>).
|
||||
<Trans>
|
||||
The next recipient to sign this document will be{' '}
|
||||
<span className="font-semibold">{form.watch('name')}</span> (
|
||||
<span className="font-semibold">{form.watch('email')}</span>).
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
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,
|
||||
type Field,
|
||||
FieldType,
|
||||
type Recipient,
|
||||
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';
|
||||
@@ -19,8 +11,10 @@ import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
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 type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
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';
|
||||
@@ -52,16 +46,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopeDistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
fields: Pick<Field, 'type' | 'recipientId'>[];
|
||||
};
|
||||
onDistribute?: () => Promise<void>;
|
||||
documentRootPath: string;
|
||||
trigger?: React.ReactNode;
|
||||
@@ -86,20 +77,20 @@ export const ZEnvelopeDistributeFormSchema = z.object({
|
||||
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
||||
|
||||
export const EnvelopeDistributeDialog = ({
|
||||
envelope,
|
||||
trigger,
|
||||
documentRootPath,
|
||||
onDistribute,
|
||||
}: EnvelopeDistributeDialogProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const recipients = envelope.recipients;
|
||||
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation();
|
||||
|
||||
@@ -134,18 +125,36 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
const distributionMethod = watch('meta.distributionMethod');
|
||||
|
||||
const recipientsMissingSignatureFields = useMemo(
|
||||
const recipientsWithIndex = useMemo(
|
||||
() =>
|
||||
envelope.recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role === RecipientRole.SIGNER &&
|
||||
!envelope.fields.some(
|
||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||
),
|
||||
),
|
||||
[envelope.recipients, envelope.fields],
|
||||
envelope.recipients.map((recipient, index) => ({
|
||||
...recipient,
|
||||
index,
|
||||
})),
|
||||
[envelope.recipients],
|
||||
);
|
||||
|
||||
const recipientsMissingSignatureFields = useMemo(
|
||||
() => getRecipientsWithMissingFields(recipientsWithIndex, envelope.fields),
|
||||
[recipientsWithIndex, envelope.fields],
|
||||
);
|
||||
|
||||
/**
|
||||
* List of recipients who must have an email due to having auth enabled.
|
||||
*/
|
||||
const recipientsMissingRequiredEmail = useMemo(() => {
|
||||
return recipientsWithIndex.filter((recipient) => {
|
||||
const auth = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
return (
|
||||
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email
|
||||
);
|
||||
});
|
||||
}, [recipientsWithIndex, envelope.authOptions]);
|
||||
|
||||
const invalidEnvelopeCode = useMemo(() => {
|
||||
if (recipientsMissingSignatureFields.length > 0) {
|
||||
return 'MISSING_SIGNATURES';
|
||||
@@ -155,8 +164,12 @@ export const EnvelopeDistributeDialog = ({
|
||||
return 'MISSING_RECIPIENTS';
|
||||
}
|
||||
|
||||
if (recipientsMissingRequiredEmail.length > 0) {
|
||||
return 'MISSING_REQUIRED_EMAIL';
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
|
||||
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
|
||||
|
||||
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
||||
try {
|
||||
@@ -189,6 +202,29 @@ export const EnvelopeDistributeDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
if (isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncing(true);
|
||||
|
||||
try {
|
||||
await syncEnvelope();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
setIsSyncing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Resync the whole envelope if the envelope is mid saving.
|
||||
if (isOpen && (isAutosaving || autosaveError)) {
|
||||
void handleSync();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) {
|
||||
return null;
|
||||
}
|
||||
@@ -208,7 +244,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!invalidEnvelopeCode ? (
|
||||
{!invalidEnvelopeCode || isSyncing ? (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
@@ -222,10 +258,10 @@ export const EnvelopeDistributeDialog = ({
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
|
||||
Email
|
||||
<Trans>Email</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
|
||||
None
|
||||
<Trans>None</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -236,7 +272,16 @@ export const EnvelopeDistributeDialog = ({
|
||||
})}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
{isSyncing ? (
|
||||
<motion.div
|
||||
key={'Flushing'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
>
|
||||
<SpinnerBox spinnerProps={{ size: 'sm' }} className="h-72" />
|
||||
</motion.div>
|
||||
) : distributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<motion.div
|
||||
key={'Emails'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
@@ -296,8 +341,10 @@ export const EnvelopeDistributeDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply To Email</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Trans>
|
||||
Reply To Email{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
@@ -315,8 +362,10 @@ export const EnvelopeDistributeDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Subject</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Trans>
|
||||
Subject{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
@@ -333,13 +382,15 @@ export const EnvelopeDistributeDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Message</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Trans>
|
||||
Message{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<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>
|
||||
@@ -347,7 +398,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="bg-background mt-2 h-16 resize-none"
|
||||
className="mt-2 h-16 resize-none bg-background"
|
||||
{...field}
|
||||
maxLength={5000}
|
||||
/>
|
||||
@@ -359,9 +410,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</fieldset>
|
||||
</Form>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{distributionMethod === DocumentDistributionMethod.NONE && (
|
||||
) : distributionMethod === DocumentDistributionMethod.NONE ? (
|
||||
<motion.div
|
||||
key={'Links'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
@@ -369,7 +418,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
className="min-h-60 rounded-lg border"
|
||||
>
|
||||
<div className="text-muted-foreground py-24 text-center text-sm">
|
||||
<div className="py-24 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
<Trans>We won't send anything to notify recipients.</Trans>
|
||||
</p>
|
||||
@@ -382,7 +431,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -393,7 +442,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
<Button loading={isSubmitting} disabled={isSyncing} type="submit">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<Trans>Send</Trans>
|
||||
) : (
|
||||
@@ -419,7 +468,22 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||
{recipientsMissingSignatureFields.map((recipient) => (
|
||||
<li key={recipient.id}>{recipient.email}</li>
|
||||
<li key={recipient.id}>
|
||||
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with('MISSING_REQUIRED_EMAIL', () => (
|
||||
<AlertDescription>
|
||||
<Trans>The following recipients require an email address:</Trans>
|
||||
|
||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||
{recipientsMissingRequiredEmail.map((recipient) => (
|
||||
<li key={recipient.id}>
|
||||
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
|
||||
@@ -117,7 +117,7 @@ export const EnvelopeItemDeleteDialog = ({
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You cannot delete this item because the document has been sent to recipients
|
||||
You cannot delete this item because the document has been sent to recipients.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Plural, useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopesBulkDeleteDialogProps = {
|
||||
envelopeIds: string[];
|
||||
envelopeType: EnvelopeType;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
export const EnvelopesBulkDeleteDialog = ({
|
||||
envelopeIds,
|
||||
envelopeType,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
...props
|
||||
}: EnvelopesBulkDeleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const trpcUtils = trpc.useUtils();
|
||||
|
||||
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
|
||||
|
||||
const { mutateAsync: bulkDeleteEnvelopes, isPending } = trpc.envelope.bulk.delete.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
// Invalidate the appropriate query based on envelope type.
|
||||
if (isDocument) {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
} else {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
}
|
||||
|
||||
if (result.failedIds.length > 0) {
|
||||
toast({
|
||||
title: isDocument ? t`Documents partially deleted` : t`Templates partially deleted`,
|
||||
description: t`${plural(result.deletedCount, {
|
||||
one: '# item deleted.',
|
||||
other: '# items deleted.',
|
||||
})} ${plural(result.failedIds.length, {
|
||||
one: '# item could not be deleted.',
|
||||
other: '# items could not be deleted.',
|
||||
})}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: isDocument ? t`Documents deleted` : t`Templates deleted`,
|
||||
description: plural(result.deletedCount, {
|
||||
one: '# item has been deleted.',
|
||||
other: '# items have been deleted.',
|
||||
}),
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while deleting the items.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isDocument ? <Trans>Delete Documents</Trans> : <Trans>Delete Templates</Trans>}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{isDocument ? (
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="You are about to delete the selected document."
|
||||
other="You are about to delete # documents."
|
||||
/>
|
||||
) : (
|
||||
<Plural
|
||||
value={envelopeIds.length}
|
||||
one="You are about to delete the selected template."
|
||||
other="You are about to delete # templates."
|
||||
/>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>
|
||||
Please note that this action is <strong>irreversible</strong>.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1">
|
||||
<Trans>Once confirmed, the following will occur:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
{isDocument ? (
|
||||
<>
|
||||
<li>
|
||||
<Trans>Selected documents will be permanently deleted</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Pending documents will have their signing process cancelled</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>All recipients will be notified</Trans>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
<Trans>Selected templates will be permanently deleted</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Direct links associated with templates will be removed</Trans>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -367,7 +367,7 @@ const BillingPlanForm = ({
|
||||
<div className="w-full text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-medium">
|
||||
<Trans>Free</Trans>
|
||||
<Trans context="Plan price">Free</Trans>
|
||||
</p>
|
||||
|
||||
<Badge size="small" variant="neutral" className="ml-1.5">
|
||||
|
||||