Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,76 @@
|
||||
---
|
||||
date: 2026-01-26
|
||||
title: Validate Signer Fields On Distribute
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Validate that signers have at least one signature field before allowing document/envelope distribution via API, matching the existing UI behavior.
|
||||
|
||||
## Background
|
||||
|
||||
The API originally allowed distributing documents/envelopes without validating that signers had signature fields assigned. This was intentional - we thought API users might have specific flows where this flexibility was needed.
|
||||
|
||||
However, after running it this way for a while, we've observed that more often than not, API users inadvertently send documents without fields assigned. This causes confusion for their recipients (who receive a document with nothing to sign) and breaks their own systems expecting a completed signing flow.
|
||||
|
||||
## Problem
|
||||
|
||||
The API allowed distributing documents/envelopes even when signers had no signature fields assigned. This was inconsistent with the UI which validates this condition before allowing distribution.
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Create centralized validation helper
|
||||
|
||||
**File**: `packages/lib/utils/recipients.ts`
|
||||
|
||||
- Added `RECIPIENT_ROLES_THAT_REQUIRE_FIELDS` constant (currently only `SIGNER`)
|
||||
- Added `getRecipientsWithMissingFields()` function that returns recipients missing required fields
|
||||
- Uses existing `isSignatureFieldType` guard from `packages/prisma/guards/is-signature-field.ts`
|
||||
|
||||
### 2. Add server-side validation
|
||||
|
||||
**File**: `packages/lib/server-only/document/send-document.ts`
|
||||
|
||||
- Added validation check that throws `AppError` with `INVALID_REQUEST` code when signers are missing signature fields
|
||||
- This blocks both v1 and v2 API distribution endpoints since they both use `sendDocument()`
|
||||
|
||||
### 3. Fix v1 API error handling
|
||||
|
||||
**File**: `packages/api/v1/implementation.ts`
|
||||
|
||||
- Changed `sendDocument` endpoint to use `AppError.toRestAPIError(err)` instead of always returning 500
|
||||
- Now returns 400 for validation errors
|
||||
|
||||
### 4. Update UI to use shared helper
|
||||
|
||||
**Files**:
|
||||
|
||||
- `apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx`
|
||||
- `packages/ui/primitives/document-flow/add-fields.tsx`
|
||||
|
||||
### 5. Consolidate `hasSignatureField` checks
|
||||
|
||||
Updated to use `isSignatureFieldType` guard (checks both `SIGNATURE` and `FREE_SIGNATURE`):
|
||||
|
||||
- `apps/remix/app/components/general/document-signing/document-signing-form.tsx`
|
||||
- `apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx`
|
||||
- `apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx`
|
||||
- `apps/remix/app/components/embed/embed-direct-template-client-page.tsx`
|
||||
- `apps/remix/app/components/embed/embed-document-signing-page-v1.tsx`
|
||||
|
||||
### 6. Add E2E tests
|
||||
|
||||
**Files**:
|
||||
|
||||
- `packages/app-tests/e2e/api/v1/document-sending.spec.ts` - 5 new tests
|
||||
- `packages/app-tests/e2e/api/v2/distribute-validation.spec.ts` - 8 new tests
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- Distribution fails when signer has no fields
|
||||
- Distribution fails when signer has only non-signature fields
|
||||
- Distribution succeeds with SIGNATURE field
|
||||
- Distribution succeeds with FREE_SIGNATURE field (v1 only via Prisma)
|
||||
- Distribution succeeds when VIEWER/CC/APPROVER have no fields
|
||||
- Distribution fails when one of multiple signers is missing signature field
|
||||
- Distribution succeeds when all signers have signature fields
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
date: 2026-01-14
|
||||
title: Simplewebauthn V13 Upgrade
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Upgrade SimpleWebAuthn packages from v9.x to v13.x to address the deprecation of `@simplewebauthn/types` and take advantage of new features and improvements.
|
||||
|
||||
## Current State
|
||||
|
||||
The codebase currently uses:
|
||||
- `@simplewebauthn/browser@9.x`
|
||||
- `@simplewebauthn/server@9.x`
|
||||
- `@simplewebauthn/types@9.x`
|
||||
|
||||
## Breaking Changes Summary (v9 → v13)
|
||||
|
||||
### v10.0.0 Breaking Changes
|
||||
1. **Minimum Node version raised to Node v20**
|
||||
2. **`generateRegistrationOptions()` now expects `Base64URLString` for `excludeCredentials` IDs** (no more `type: 'public-key'` needed)
|
||||
3. **`generateAuthenticationOptions()` now expects `Base64URLString` for `allowCredentials` IDs**
|
||||
4. **`credentialID` returned from verification methods is now `Base64URLString`** instead of `Uint8Array`
|
||||
5. **`AuthenticatorDevice.credentialID` is now `Base64URLString`**
|
||||
6. **`rpID` is now required when calling `generateAuthenticationOptions()`**
|
||||
7. **`generateRegistrationOptions()` will generate random user IDs** if not provided
|
||||
8. **`user.id` is treated as base64url string in `startRegistration()`**
|
||||
9. **`userHandle` is treated as base64url string in `startAuthentication()`**
|
||||
|
||||
### v11.0.0 Breaking Changes
|
||||
1. **Positional arguments in `startRegistration()` and `startAuthentication()` replaced by object**
|
||||
- Before: `startRegistration(options)`
|
||||
- After: `startRegistration({ optionsJSON: options })`
|
||||
- Before: `startAuthentication(options)`
|
||||
- After: `startAuthentication({ optionsJSON: options })`
|
||||
2. **`AuthenticatorDevice` type renamed to `WebAuthnCredential`**
|
||||
- `credentialID` → `credential.id`
|
||||
- `credentialPublicKey` → `credential.publicKey`
|
||||
3. **`verifyRegistrationResponse()` returns `registrationInfo.credential` instead of individual properties**
|
||||
- `credentialID` → `credential.id`
|
||||
- `credentialPublicKey` → `credential.publicKey`
|
||||
- `counter` → `credential.counter`
|
||||
- `transports` are now in `credential.transports`
|
||||
4. **`verifyAuthenticationResponse()` uses `credential` argument instead of `authenticator`**
|
||||
|
||||
### v13.0.0 Breaking Changes
|
||||
1. **`@simplewebauthn/types` package is retired**
|
||||
- Types are now exported from `@simplewebauthn/browser` and `@simplewebauthn/server`
|
||||
- Import types from `@simplewebauthn/server` instead
|
||||
|
||||
## Files to Update
|
||||
|
||||
### Package Changes
|
||||
1. Remove `@simplewebauthn/types` dependency
|
||||
2. Update `@simplewebauthn/browser` to `^13.2.2`
|
||||
3. Update `@simplewebauthn/server` to `^13.2.2`
|
||||
|
||||
### Server-side Files
|
||||
|
||||
#### 1. `packages/lib/server-only/auth/create-passkey-registration-options.ts`
|
||||
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
|
||||
- Remove `type: 'public-key'` from `excludeCredentials` items
|
||||
- Update `userID` to use `isoUint8Array.fromUTF8String()` for proper encoding
|
||||
|
||||
#### 2. `packages/lib/server-only/auth/create-passkey-authentication-options.ts`
|
||||
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
|
||||
- Remove `type: 'public-key'` from `allowCredentials` items
|
||||
|
||||
#### 3. `packages/lib/server-only/auth/create-passkey-signin-options.ts`
|
||||
- No changes needed (already using correct options)
|
||||
|
||||
#### 4. `packages/lib/server-only/auth/create-passkey.ts`
|
||||
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
|
||||
- Update to use new `registrationInfo.credential` structure:
|
||||
- `credentialID` → `credential.id`
|
||||
- `credentialPublicKey` → `credential.publicKey`
|
||||
- `counter` → `credential.counter`
|
||||
- Note: `credential.id` is now a `Base64URLString`, so `Buffer.from(credentialID)` needs updating
|
||||
|
||||
#### 5. `packages/lib/server-only/document/is-recipient-authorized.ts`
|
||||
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`:
|
||||
- Change `authenticator: { credentialID, credentialPublicKey, counter }` to `credential: { id, publicKey, counter }`
|
||||
- Since `credential.id` is now base64url string, convert stored `credentialId` buffer to base64url
|
||||
|
||||
#### 6. `packages/auth/server/routes/passkey.ts`
|
||||
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`
|
||||
- Same changes as `is-recipient-authorized.ts`
|
||||
|
||||
#### 7. `packages/trpc/server/auth-router/create-passkey.ts`
|
||||
- Change import from `@simplewebauthn/types` to `@simplewebauthn/server`
|
||||
|
||||
### Browser-side Files
|
||||
|
||||
#### 8. `apps/remix/app/components/dialogs/passkey-create-dialog.tsx`
|
||||
- Update `startRegistration()` call:
|
||||
- Before: `startRegistration(passkeyRegistrationOptions)`
|
||||
- After: `startRegistration({ optionsJSON: passkeyRegistrationOptions })`
|
||||
|
||||
#### 9. `apps/remix/app/components/forms/signin.tsx`
|
||||
- Update `startAuthentication()` call:
|
||||
- Before: `startAuthentication(options)`
|
||||
- After: `startAuthentication({ optionsJSON: options })`
|
||||
|
||||
#### 10. `apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx`
|
||||
- Update `startAuthentication()` call:
|
||||
- Before: `startAuthentication(options)`
|
||||
- After: `startAuthentication({ optionsJSON: options })`
|
||||
|
||||
### Database/Schema Considerations
|
||||
|
||||
The database stores `credentialId` as `Bytes`. The new API returns `credential.id` as `Base64URLString`. We need to:
|
||||
1. When **storing** a new passkey: Convert from `Base64URLString` to `Buffer`
|
||||
2. When **passing to verification**: Convert from `Buffer` to `Base64URLString`
|
||||
|
||||
Use `isoBase64URL` helper from `@simplewebauthn/server/helpers` for these conversions.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Update package.json dependencies
|
||||
```bash
|
||||
npm uninstall @simplewebauthn/types
|
||||
npm install @simplewebauthn/browser@^13.2.2 @simplewebauthn/server@^13.2.2
|
||||
```
|
||||
|
||||
### Step 2: Update type imports
|
||||
Replace all `@simplewebauthn/types` imports with `@simplewebauthn/server`
|
||||
|
||||
### Step 3: Update browser-side API calls
|
||||
- `startRegistration(options)` → `startRegistration({ optionsJSON: options })`
|
||||
- `startAuthentication(options)` → `startAuthentication({ optionsJSON: options })`
|
||||
|
||||
### Step 4: Update server-side registration
|
||||
- Update `excludeCredentials` format (remove `type: 'public-key'`)
|
||||
- Update `userID` encoding if needed
|
||||
- Update `verifyRegistrationResponse()` result handling for new `credential` structure
|
||||
|
||||
### Step 5: Update server-side authentication
|
||||
- Update `allowCredentials` format (remove `type: 'public-key'`)
|
||||
- Update `verifyAuthenticationResponse()` to use `credential` instead of `authenticator`
|
||||
- Handle `Base64URLString` for `credential.id`
|
||||
|
||||
### Step 6: Update credential storage/retrieval
|
||||
- When storing: Convert `Base64URLString` to `Buffer`
|
||||
- When reading: Convert `Buffer` to `Base64URLString`
|
||||
|
||||
### Step 7: Test passkey flows
|
||||
1. Test passkey creation
|
||||
2. Test passkey sign-in
|
||||
3. Test passkey authentication for document signing
|
||||
4. Test passkey deletion
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Converting stored Buffer to Base64URLString for verification
|
||||
```typescript
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
||||
|
||||
// When reading from database (Buffer) and passing to verification
|
||||
const credential = {
|
||||
id: isoBase64URL.fromBuffer(passkey.credentialId),
|
||||
publicKey: new Uint8Array(passkey.credentialPublicKey),
|
||||
counter: Number(passkey.counter),
|
||||
transports: passkey.transports,
|
||||
};
|
||||
```
|
||||
|
||||
### Converting Base64URLString to Buffer for storage
|
||||
```typescript
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
||||
|
||||
// When storing from registration response
|
||||
const credentialIdBuffer = Buffer.from(
|
||||
isoBase64URL.toBuffer(registrationInfo.credential.id)
|
||||
);
|
||||
```
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
1. **Database compatibility**: The `credentialId` is stored as `Bytes` in the database. The new API uses `Base64URLString`. We need proper conversion functions.
|
||||
- **Mitigation**: Use `isoBase64URL.fromBuffer()` and `isoBase64URL.toBuffer()` for conversions
|
||||
|
||||
2. **Existing passkeys**: Existing passkeys should continue to work as long as conversion is done correctly.
|
||||
- **Mitigation**: Test with existing passkeys after upgrade
|
||||
|
||||
3. **Browser compatibility**: v10+ requires newer browser APIs.
|
||||
- **Mitigation**: `browserSupportsWebAuthn()` already handles this check
|
||||
@@ -59,6 +59,18 @@ NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH=
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
|
||||
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
|
||||
# OPTIONAL: The path to the certificate chain file for the gcloud-hsm signing transport.
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH=
|
||||
# OPTIONAL: The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport.
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS=
|
||||
# OPTIONAL: The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport.
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH=
|
||||
# OPTIONAL: Comma-separated list of timestamp authority URLs for PDF signing (enables LTV and archival timestamps).
|
||||
NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY=
|
||||
# OPTIONAL: Contact info to embed in PDF signatures. Defaults to the webapp URL.
|
||||
NEXT_PUBLIC_SIGNING_CONTACT_INFO=
|
||||
# OPTIONAL: Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached.
|
||||
NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER=
|
||||
|
||||
# [[STORAGE]]
|
||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||
|
||||
@@ -65,6 +65,47 @@ jobs:
|
||||
env:
|
||||
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
|
||||
|
||||
- name: Build the chromium docker image
|
||||
env:
|
||||
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
|
||||
run: |
|
||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||
GIT_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
docker build \
|
||||
-f ./docker/Dockerfile.chromium \
|
||||
--progress=plain \
|
||||
--build-arg TAG="$GIT_SHA" \
|
||||
-t "documenso/documenso-$BUILD_PLATFORM:latest-chromium" \
|
||||
-t "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" \
|
||||
-t "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \
|
||||
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest-chromium" \
|
||||
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" \
|
||||
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \
|
||||
.
|
||||
|
||||
- name: Push the chromium docker image to DockerHub
|
||||
env:
|
||||
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
|
||||
run: |
|
||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||
GIT_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
docker push "documenso/documenso-$BUILD_PLATFORM:latest-chromium"
|
||||
docker push "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium"
|
||||
docker push "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \
|
||||
|
||||
- name: Push the chromium docker image to GitHub Container Registry
|
||||
env:
|
||||
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
|
||||
run: |
|
||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||
GIT_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest-chromium"
|
||||
docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium"
|
||||
docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium"
|
||||
|
||||
create_and_publish_manifest:
|
||||
name: Create and publish manifest
|
||||
runs-on: ubuntu-latest
|
||||
@@ -125,6 +166,43 @@ jobs:
|
||||
docker manifest push documenso/documenso:$GIT_SHA
|
||||
docker manifest push documenso/documenso:$APP_VERSION
|
||||
|
||||
- name: Create and push DockerHub chromium manifest
|
||||
run: |
|
||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||
GIT_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
# Check if the version is stable (no rc or beta in the version)
|
||||
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
docker manifest create \
|
||||
documenso/documenso:latest-chromium \
|
||||
--amend documenso/documenso-amd64:latest-chromium \
|
||||
--amend documenso/documenso-arm64:latest-chromium
|
||||
|
||||
docker manifest push documenso/documenso:latest-chromium
|
||||
fi
|
||||
|
||||
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
docker manifest create \
|
||||
documenso/documenso:rc-chromium \
|
||||
--amend documenso/documenso-amd64:rc-chromium \
|
||||
--amend documenso/documenso-arm64:rc-chromium
|
||||
|
||||
docker manifest push documenso/documenso:rc-chromium
|
||||
fi
|
||||
|
||||
docker manifest create \
|
||||
documenso/documenso:$GIT_SHA-chromium \
|
||||
--amend documenso/documenso-amd64:$GIT_SHA-chromium \
|
||||
--amend documenso/documenso-arm64:$GIT_SHA-chromium
|
||||
|
||||
docker manifest create \
|
||||
documenso/documenso:$APP_VERSION-chromium \
|
||||
--amend documenso/documenso-amd64:$APP_VERSION-chromium \
|
||||
--amend documenso/documenso-arm64:$APP_VERSION-chromium
|
||||
|
||||
docker manifest push documenso/documenso:$GIT_SHA-chromium
|
||||
docker manifest push documenso/documenso:$APP_VERSION-chromium
|
||||
|
||||
- name: Create and push Github Container Registry manifest
|
||||
run: |
|
||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||
@@ -161,3 +239,40 @@ jobs:
|
||||
|
||||
docker manifest push ghcr.io/documenso/documenso:$GIT_SHA
|
||||
docker manifest push ghcr.io/documenso/documenso:$APP_VERSION
|
||||
|
||||
- name: Create and push Github Container Registry chromium manifest
|
||||
run: |
|
||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||
GIT_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
# Check if the version is stable (no rc or beta in the version)
|
||||
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
docker manifest create \
|
||||
ghcr.io/documenso/documenso:latest-chromium \
|
||||
--amend ghcr.io/documenso/documenso-amd64:latest-chromium \
|
||||
--amend ghcr.io/documenso/documenso-arm64:latest-chromium
|
||||
|
||||
docker manifest push ghcr.io/documenso/documenso:latest-chromium
|
||||
fi
|
||||
|
||||
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
docker manifest create \
|
||||
ghcr.io/documenso/documenso:rc-chromium \
|
||||
--amend ghcr.io/documenso/documenso-amd64:rc-chromium \
|
||||
--amend ghcr.io/documenso/documenso-arm64:rc-chromium
|
||||
|
||||
docker manifest push ghcr.io/documenso/documenso:rc-chromium
|
||||
fi
|
||||
|
||||
docker manifest create \
|
||||
ghcr.io/documenso/documenso:$GIT_SHA-chromium \
|
||||
--amend ghcr.io/documenso/documenso-amd64:$GIT_SHA-chromium \
|
||||
--amend ghcr.io/documenso/documenso-arm64:$GIT_SHA-chromium
|
||||
|
||||
docker manifest create \
|
||||
ghcr.io/documenso/documenso:$APP_VERSION-chromium \
|
||||
--amend ghcr.io/documenso/documenso-amd64:$APP_VERSION-chromium \
|
||||
--amend ghcr.io/documenso/documenso-arm64:$APP_VERSION-chromium
|
||||
|
||||
docker manifest push ghcr.io/documenso/documenso:$GIT_SHA-chromium
|
||||
docker manifest push ghcr.io/documenso/documenso:$APP_VERSION-chromium
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"next": "^15.5.7",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -148,6 +148,7 @@ This method avoids file permission issues by creating the certificate directly i
|
||||
|
||||
# Generate certificate inside container using environment variable
|
||||
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
|
||||
mkdir -p /app/certs && \
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout /tmp/private.key \
|
||||
-out /tmp/certificate.crt \
|
||||
@@ -290,10 +291,13 @@ For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)](
|
||||
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
|
||||
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
|
||||
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
||||
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. |
|
||||
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. |
|
||||
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
|
||||
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
|
||||
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
|
||||
|
||||
@@ -53,15 +53,21 @@ Have the Certificate Authority sign the Certificate Signing Request.
|
||||
|
||||
Configure your instance to use the new certificate by configuring the following environment variables in your `.env` file:
|
||||
|
||||
| Environment Variable | Description |
|
||||
| :-------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM _PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_ APPLICATION_CREDENTIALS_CONTENTS` | The Google Cloud Credentials file path for the gcloud-hsm signing transport. This field is optional. |
|
||||
| Environment Variable | Description |
|
||||
| :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The transport used for document signing. Available options: local (default), gcloud-hsm |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the local file-based signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The local file path to the .p12 file to use for the local signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the .p12 file to use for the local signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded contents of the Google Cloud HSM public certificate file for the gcloud-hsm signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded contents of the certificate chain for the gcloud-hsm signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm signing transport. This field is optional. |
|
||||
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing. Enables LTV and archival timestamps. This field is optional. |
|
||||
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. This field is optional. |
|
||||
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use the legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. This field is optional. |
|
||||
|
||||
</Steps>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: Telemetry
|
||||
description: Learn about the telemetry data that Documenso collects from self-hosted instances.
|
||||
---
|
||||
|
||||
# Telemetry
|
||||
|
||||
Documenso collects anonymous telemetry data from self-hosted instances to help us understand how the software is being used and make improvements to the product. This telemetry is enabled by default, but you can easily disable it if you prefer.
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Learn how to use webhooks to receive real-time notifications about
|
||||
|
||||
# Webhooks
|
||||
|
||||
Webhooks are HTTP callbacks triggered by specific events. When the user subscribes to a specific event, and that event occurs, the webhook makes an HTTP request to the URL provided by the user. The request can be a simple notification or carry a payload with more information about the event.
|
||||
Webhooks are HTTP callbacks triggered by specific events. When you subscribe to a specific event and that event occurs, the webhook makes an HTTP request to the URL you provide. The request can be a simple notification or carry a payload with more information about the event.
|
||||
|
||||
Some of the common use cases for webhooks include:
|
||||
|
||||
@@ -25,13 +25,13 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
|
||||
|
||||
## Create a webhook subscription
|
||||
|
||||
You can create a webhook subscription from the user settings page. Click on your avatar in the top right corner of the dashboard and select "**[User settings](https://app.documenso.com/settings)**" from the dropdown menu.
|
||||
You can create a webhook subscription from the team settings page. Click your avatar in the top right corner of the dashboard and select "Team settings" from the dropdown menu.
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,4 +5,5 @@ export default {
|
||||
fields: 'Document Fields',
|
||||
'email-preferences': 'Email Preferences',
|
||||
'ai-detection': 'AI Recipient & Field Detection',
|
||||
'default-recipients': 'Default Recipients',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Default Document Recipients
|
||||
description: Learn how to set default recipients with various roles for your documents.
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Default Document Recipients
|
||||
|
||||
Documenso allows you to set default recipients for your documents. This is useful when you require specific recipients to be added to every document you send.
|
||||
|
||||
You can add default recipients with the same roles as the recipients you can add when sending a document:
|
||||
|
||||
- **Signer** - The recipient will be required to sign the document.
|
||||
- **Approver** - The recipient will be required to approve the document.
|
||||
- **Viewer** - The recipient will be required to view the document.
|
||||
- **CC** - The recipient will receive a copy of the document.
|
||||
|
||||
You can set default recipients at the organisation or team level.
|
||||
|
||||
### Organisation level
|
||||
|
||||
To set default recipients at the organisation level, navigate to the organisation settings page and click the "Document" tab under the "Preferences" section.
|
||||
|
||||
Then scroll down to the "Default Recipients" section and add the recipients you want to be included in every document you send.
|
||||
|
||||

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

|
||||
@@ -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,28 @@ import { Callout } from 'nextra/components';
|
||||
|
||||
# Fair Use Policy
|
||||
|
||||
### Why
|
||||
We offer our plans without any limits on volume because we want our users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using.
|
||||
|
||||
We 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:
|
||||
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:
|
||||
|
||||
### 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 accounts as much as you like (they are meant to offer a lot) while respecting 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.
|
||||
pricing. We won’t block your account without reaching out. You can [message
|
||||
us](mailto:support@documenso.com) for questions.
|
||||
</Callout>
|
||||
|
||||
### 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 you are part of
|
||||
- Use the API and automation tools to automate all your signing workflows
|
||||
- Experiment with the plans and integrations, testing what you want to build
|
||||
|
||||
### 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
|
||||
- Let this policy make you overthink. 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,3 +1,8 @@
|
||||
---
|
||||
title: Enterprise Edition
|
||||
description: Learn about the Enterprise Edition of Documenso.
|
||||
---
|
||||
|
||||
import { Callout } from 'nextra/components';
|
||||
|
||||
# Enterprise Edition
|
||||
|
||||
@@ -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: 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.5.7"
|
||||
"next": "15.5.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "18.3.27",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,13 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
DocumentDistributionMethod,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
} from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -20,6 +14,7 @@ import * as z from 'zod';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -140,14 +135,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
);
|
||||
|
||||
const recipientsMissingSignatureFields = useMemo(
|
||||
() =>
|
||||
recipientsWithIndex.filter(
|
||||
(recipient) =>
|
||||
recipient.role === RecipientRole.SIGNER &&
|
||||
!envelope.fields.some(
|
||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||
),
|
||||
),
|
||||
() => getRecipientsWithMissingFields(recipientsWithIndex, envelope.fields),
|
||||
[recipientsWithIndex, envelope.fields],
|
||||
);
|
||||
|
||||
@@ -353,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>
|
||||
@@ -372,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>
|
||||
@@ -390,8 +382,10 @@ 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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -121,7 +121,7 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
|
||||
<Trans>
|
||||
Once you update your DNS records, it may take up to 48 hours for it to be propogated.
|
||||
Once the DNS propagation is complete you will need to come back and press the "Sync"
|
||||
domains button
|
||||
domains button.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -148,8 +148,8 @@ export const OrganisationMemberUpdateDialog = ({
|
||||
|
||||
<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>
|
||||
|
||||
@@ -73,7 +73,9 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
|
||||
try {
|
||||
const passkeyRegistrationOptions = await createPasskeyRegistrationOptions();
|
||||
|
||||
const registrationResult = await startRegistration(passkeyRegistrationOptions);
|
||||
const registrationResult = await startRegistration({
|
||||
optionsJSON: passkeyRegistrationOptions,
|
||||
});
|
||||
|
||||
await createPasskey({
|
||||
passkeyName,
|
||||
|
||||
@@ -82,9 +82,7 @@ export const SignFieldCheckboxDialog = createCallable<
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Checkbox Field</Trans>
|
||||
</DialogTitle>
|
||||
<DialogTitle>{fieldMeta.label || <Trans>Select Options</Trans>}</DialogTitle>
|
||||
|
||||
<DialogDescription
|
||||
className={cn('mt-4', {
|
||||
@@ -143,7 +141,7 @@ export const SignFieldCheckboxDialog = createCallable<
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id={`checkbox-value-${index}`}
|
||||
className="data-[state=checked]:bg-primary border-foreground/30 h-5 w-5"
|
||||
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
|
||||
checked={field.value.checked}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange({
|
||||
@@ -154,7 +152,7 @@ export const SignFieldCheckboxDialog = createCallable<
|
||||
/>
|
||||
|
||||
<label
|
||||
className="text-muted-foreground ml-2 w-full text-sm"
|
||||
className="ml-2 w-full text-sm text-muted-foreground"
|
||||
htmlFor={`checkbox-value-${index}`}
|
||||
>
|
||||
{value.value}
|
||||
@@ -174,7 +172,7 @@ export const SignFieldCheckboxDialog = createCallable<
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
<Trans>Confirm</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
|
||||
@@ -50,11 +50,11 @@ export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, st
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Email</Trans>
|
||||
<Trans>Enter Email</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Sign your email into the field</Trans>
|
||||
<Trans>Please enter your email address</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -83,7 +83,7 @@ export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, st
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
<Trans>Enter</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
|
||||
@@ -48,11 +48,11 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Initials</Trans>
|
||||
<Trans>Enter Initials</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Sign your initials into the field</Trans>
|
||||
<Trans>Please enter your initials</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -84,7 +84,7 @@ export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogPro
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
<Trans>Enter</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
|
||||
@@ -47,11 +47,11 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Name</Trans>
|
||||
<Trans>Enter Name</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Sign your full name into the field</Trans>
|
||||
<Trans>Please enter your full name</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -80,7 +80,7 @@ export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, stri
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
<Trans>Enter</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
@@ -107,12 +106,10 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Number Field</Trans>
|
||||
</DialogTitle>
|
||||
<DialogTitle>{fieldMeta.label || <Trans>Enter Number</Trans>}</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Insert a value into the number field</Trans>
|
||||
<Trans>Please enter a number</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -127,8 +124,6 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
|
||||
name="number"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
{fieldMeta.label && <FormLabel>{fieldMeta.label}</FormLabel>}
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={fieldMeta.placeholder ?? t`Enter your number here`}
|
||||
@@ -150,7 +145,7 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
<Trans>Enter</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DocumentSigningDisclosure } from '../general/document-signing/document-
|
||||
|
||||
export type SignFieldSignatureDialogProps = {
|
||||
initialSignature?: string;
|
||||
fullName?: string;
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
@@ -28,6 +29,7 @@ export const SignFieldSignatureDialog = createCallable<
|
||||
>(
|
||||
({
|
||||
call,
|
||||
fullName,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
@@ -46,6 +48,7 @@ export const SignFieldSignatureDialog = createCallable<
|
||||
</DialogHeader>
|
||||
|
||||
<SignaturePad
|
||||
fullName={fullName}
|
||||
value={localSignature ?? ''}
|
||||
onChange={({ value }) => setLocalSignature(value)}
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
@@ -52,12 +51,10 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
|
||||
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Sign Text Field</Trans>
|
||||
</DialogTitle>
|
||||
<DialogTitle>{fieldMeta?.label || <Trans>Enter Text</Trans>}</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Insert a value into the text field</Trans>
|
||||
<Trans>Please enter a value</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -72,8 +69,6 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
|
||||
name="text"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
{fieldMeta?.label && <FormLabel>{fieldMeta?.label}</FormLabel>}
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
id="custom-text"
|
||||
@@ -89,7 +84,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
|
||||
{fieldMeta?.characterLimit !== undefined &&
|
||||
fieldMeta?.characterLimit > 0 &&
|
||||
!fieldState.error && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<Plural
|
||||
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
|
||||
one="# character remaining"
|
||||
@@ -107,7 +102,7 @@ export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, stri
|
||||
</Button>
|
||||
|
||||
<Button type="submit">
|
||||
<Trans>Sign</Trans>
|
||||
<Trans>Enter</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -30,6 +31,13 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -53,26 +61,34 @@ export const TeamDeleteDialog = ({
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useSession();
|
||||
|
||||
const currentOrganisation = useCurrentOrganisation();
|
||||
|
||||
const deleteMessage = _(msg`delete ${teamName}`);
|
||||
|
||||
const filteredTeams = currentOrganisation.teams.filter((team) => team.id !== teamId);
|
||||
|
||||
const ZDeleteTeamFormSchema = z.object({
|
||||
teamName: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
||||
}),
|
||||
transferTeamId: z.string().optional(),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZDeleteTeamFormSchema),
|
||||
defaultValues: {
|
||||
teamName: '',
|
||||
transferTeamId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
const onFormSubmit = async (data: z.infer<typeof ZDeleteTeamFormSchema>) => {
|
||||
try {
|
||||
await deleteTeam({ teamId });
|
||||
const transferTeamId = data.transferTeamId ? parseInt(data.transferTeamId, 10) : undefined;
|
||||
|
||||
await deleteTeam({ teamId, transferTeamId: transferTeamId || undefined });
|
||||
|
||||
await refreshSession();
|
||||
|
||||
@@ -168,6 +184,43 @@ export const TeamDeleteDialog = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{filteredTeams.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="transferTeamId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Transfer documents to a different team</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={_(msg`Don't transfer (Delete all documents)`)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="-1">
|
||||
<Trans>Don't transfer (Delete all documents)</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{filteredTeams.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id.toString()}>
|
||||
{team.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
|
||||
@@ -146,7 +146,7 @@ export const TeamMemberUpdateDialog = ({
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are currently updating <span className="font-bold">{memberName}.</span>
|
||||
You are currently updating <span className="font-bold">{memberName}</span>.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -137,12 +137,12 @@ export const TemplateBulkSendDialog = ({
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||
<div className="bg-muted/70 rounded-lg border p-4">
|
||||
<div className="rounded-lg border bg-muted/70 p-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
<Trans>CSV Structure</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
For each recipient, provide their email (required) and name (optional) in separate
|
||||
columns. Download the template CSV below for the correct format.
|
||||
@@ -153,7 +153,7 @@ export const TemplateBulkSendDialog = ({
|
||||
<Trans>Current recipients:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
|
||||
<ul className="mt-2 list-inside list-disc text-sm text-muted-foreground">
|
||||
{recipients.map((recipient, index) => (
|
||||
<li key={index}>
|
||||
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
|
||||
@@ -167,7 +167,7 @@ export const TemplateBulkSendDialog = ({
|
||||
<Trans>Download Template CSV</Trans>
|
||||
</Button>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Pre-formatted CSV template with example data.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -200,14 +200,14 @@ export const TemplateBulkSendDialog = ({
|
||||
) : (
|
||||
<div className="flex h-10 items-center rounded-md border px-3">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<FileIcon className="text-muted-foreground h-4 w-4" />
|
||||
<FileIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-sm">{value.name}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="text-destructive hover:text-destructive p-0 text-xs"
|
||||
className="p-0 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => onChange(null)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
@@ -220,9 +220,9 @@ export const TemplateBulkSendDialog = ({
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{error && <p className="text-destructive text-sm">{error.message}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
|
||||
template defaults.
|
||||
@@ -247,7 +247,7 @@ export const TemplateBulkSendDialog = ({
|
||||
|
||||
<label
|
||||
htmlFor="send-immediately"
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
className="ml-2 flex items-center text-sm text-muted-foreground"
|
||||
>
|
||||
<Trans>Send documents to recipients immediately</Trans>
|
||||
</label>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FilePlus, Loader } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type TemplateCreateDialogProps = {
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user } = useSession();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||
|
||||
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
|
||||
if (isUploadingFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingFile(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
title: file.name,
|
||||
folderId: folderId,
|
||||
} satisfies TCreateTemplatePayloadSchema;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('file', file);
|
||||
|
||||
const { envelopeId: id } = await createTemplate(formData);
|
||||
|
||||
toast({
|
||||
title: _(msg`Template document uploaded`),
|
||||
description: _(
|
||||
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
|
||||
),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setShowTemplateCreateDialog(false);
|
||||
|
||||
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`Please try again later.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
setIsUploadingFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={showTemplateCreateDialog}
|
||||
onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer" disabled={!user.emailVerified}>
|
||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Template (Legacy)</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="w-full max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>New Template</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Templates allow you to quickly generate documents with pre-filled recipients and
|
||||
fields.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
|
||||
|
||||
{isUploadingFile && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isUploadingFile}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -54,6 +54,8 @@ type TemplateDirectLinkDialogProps = {
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
recipients: Recipient[];
|
||||
trigger?: React.ReactNode;
|
||||
onCreateSuccess?: () => Promise<void> | void;
|
||||
onDeleteSuccess?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
|
||||
@@ -63,6 +65,8 @@ export const TemplateDirectLinkDialog = ({
|
||||
directLink,
|
||||
recipients,
|
||||
trigger,
|
||||
onCreateSuccess,
|
||||
onDeleteSuccess,
|
||||
}: TemplateDirectLinkDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { quota, remaining } = useLimits();
|
||||
@@ -97,6 +101,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
} = trpcReact.template.createTemplateDirectLink.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
await revalidate();
|
||||
await onCreateSuccess?.();
|
||||
|
||||
setToken(data.token);
|
||||
setIsEnabled(data.enabled);
|
||||
@@ -142,6 +147,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
trpcReact.template.deleteTemplateDirectLink.useMutation({
|
||||
onSuccess: async () => {
|
||||
await revalidate();
|
||||
await onDeleteSuccess?.();
|
||||
|
||||
setOpen(false);
|
||||
setToken(null);
|
||||
@@ -234,7 +240,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold">{_(step.title)}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{_(step.description)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -320,13 +326,13 @@ export const TemplateDirectLinkDialog = ({
|
||||
onClick={async () => onRecipientTableRowClick(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>{row.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
|
||||
<p className="text-xs text-muted-foreground/70">{row.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
|
||||
</TableCell>
|
||||
|
||||
@@ -350,7 +356,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
<DialogFooter className="mx-auto">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{validDirectTemplateRecipients.length !== 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Or</Trans>
|
||||
</p>
|
||||
)}
|
||||
@@ -392,7 +398,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
<TooltipTrigger tabIndex={-1} className="ml-2">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
|
||||
<Trans>
|
||||
Disabling direct link signing will prevent anyone from accessing the
|
||||
link.
|
||||
|
||||
@@ -219,9 +219,8 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
A secret that will be sent to your URL so you can verify that the request
|
||||
has been sent by Documenso
|
||||
has been sent by Documenso.
|
||||
</Trans>
|
||||
.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -3,8 +3,14 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import {
|
||||
type DocumentMeta,
|
||||
type EnvelopeItem,
|
||||
type Field,
|
||||
FieldType,
|
||||
type Recipient,
|
||||
type Signature,
|
||||
} from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useSearchParams } from 'react-router';
|
||||
@@ -18,6 +24,7 @@ import {
|
||||
isRequiredField,
|
||||
} from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
@@ -96,7 +103,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
|
||||
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
|
||||
|
||||
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
|
||||
@@ -438,6 +445,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
className="mt-2"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
disableAnimation
|
||||
fullName={fullName}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
|
||||
@@ -4,13 +4,14 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
|
||||
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
@@ -115,7 +116,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
[fields],
|
||||
);
|
||||
|
||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
||||
|
||||
@@ -455,6 +456,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
className="mt-2"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
disableAnimation
|
||||
fullName={fullName}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
|
||||
import { DocumentStatus, SigningStatus } from '@prisma/client';
|
||||
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
TRemovedSignedFieldWithTokenMutationSchema,
|
||||
@@ -83,7 +84,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
const { mutateAsync: completeDocumentWithToken } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const hasSignatureField = document?.fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const [pendingFields, completedFields] = [
|
||||
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
|
||||
@@ -319,6 +320,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
disableAnimation
|
||||
fullName={fullName}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={
|
||||
|
||||
@@ -3,7 +3,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationType } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -17,14 +17,19 @@ import {
|
||||
isValidLanguageCode,
|
||||
} from '@documenso/lib/constants/i18n';
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
|
||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||
import {
|
||||
type TDocumentMetaDateFormat,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import {
|
||||
@@ -45,6 +50,10 @@ import {
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
||||
|
||||
/**
|
||||
* Can't infer this from the schema since we need to keep the schema inside the component to allow
|
||||
* it to be dynamic.
|
||||
@@ -58,6 +67,8 @@ export type TDocumentPreferencesFormSchema = {
|
||||
includeSigningCertificate: boolean | null;
|
||||
includeAuditLog: boolean | null;
|
||||
signatureTypes: DocumentSignatureType[];
|
||||
defaultRecipients: TDefaultRecipients | null;
|
||||
delegateDocumentOwnership: boolean | null;
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
};
|
||||
|
||||
@@ -73,6 +84,8 @@ type SettingsSubset = Pick<
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
| 'defaultRecipients'
|
||||
| 'delegateDocumentOwnership'
|
||||
| 'aiFeaturesEnabled'
|
||||
>;
|
||||
|
||||
@@ -92,6 +105,7 @@ export const DocumentPreferencesForm = ({
|
||||
const { t } = useLingui();
|
||||
const { user, organisations } = useSession();
|
||||
const currentOrganisation = useCurrentOrganisation();
|
||||
const optionalTeam = useOptionalCurrentTeam();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
|
||||
@@ -109,6 +123,8 @@ export const DocumentPreferencesForm = ({
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
|
||||
delegateDocumentOwnership: z.boolean().nullable(),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
@@ -125,6 +141,10 @@ export const DocumentPreferencesForm = ({
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
defaultRecipients: settings.defaultRecipients
|
||||
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
|
||||
: null,
|
||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
@@ -515,6 +535,140 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultRecipients"
|
||||
render={({ field }) => {
|
||||
const recipients = field.value ?? [];
|
||||
|
||||
return (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Recipients</Trans>
|
||||
</FormLabel>
|
||||
|
||||
{canInherit && (
|
||||
<Select
|
||||
value={field.value === null ? '-1' : '0'}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : [])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={'0'}>
|
||||
<Trans>Override organisation settings</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{(field.value !== null || !canInherit) && (
|
||||
<div className="space-y-4">
|
||||
<DefaultRecipientsMultiSelectCombobox
|
||||
listValues={recipients}
|
||||
onChange={field.onChange}
|
||||
organisationId={!canInherit ? currentOrganisation.id : undefined}
|
||||
teamId={canInherit ? optionalTeam?.id : undefined}
|
||||
/>
|
||||
|
||||
{recipients.map((recipient, index) => {
|
||||
return (
|
||||
<div
|
||||
key={recipient.email}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarFallback={recipientAbbreviation(recipient)}
|
||||
primaryText={
|
||||
<span className="text-sm font-medium">
|
||||
{recipient.name || recipient.email}
|
||||
</span>
|
||||
}
|
||||
secondaryText={
|
||||
recipient.name ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{recipient.email}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<RecipientRoleSelect
|
||||
value={recipient.role}
|
||||
onValueChange={(role: RecipientRole) => {
|
||||
field.onChange(
|
||||
recipients.map((recipient, idx) =>
|
||||
idx === index ? { ...recipient, role } : recipient,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormDescription>
|
||||
<Trans>Recipients that will be automatically added to new documents.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="delegateDocumentOwnership"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Delegate Document Ownership</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Enable team API tokens to delegate document ownership to another team member.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAiFeaturesConfigured && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -110,7 +110,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
||||
<Input id="email" type="email" className="mt-2 bg-muted" value={user.email} disabled />
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
@@ -124,6 +124,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||
<FormControl>
|
||||
<SignaturePadDialog
|
||||
disabled={isSubmitting}
|
||||
fullName={user.name ?? ''}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
|
||||
@@ -171,7 +171,7 @@ export const SignInForm = ({
|
||||
|
||||
const { options, sessionId } = await createPasskeySigninOptions();
|
||||
|
||||
const credential = await startAuthentication(options);
|
||||
const credential = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
await authClient.passkey.signIn({
|
||||
credential: JSON.stringify(credential),
|
||||
@@ -244,11 +244,11 @@ export const SignInForm = ({
|
||||
const errorMessage = match(error.code)
|
||||
.with(
|
||||
AuthenticationErrorCode.InvalidCredentials,
|
||||
() => msg`The email or password provided is incorrect`,
|
||||
() => msg`The email or password provided is incorrect.`,
|
||||
)
|
||||
.with(
|
||||
AuthenticationErrorCode.InvalidTwoFactorCode,
|
||||
() => msg`The two-factor authentication code provided is incorrect`,
|
||||
() => msg`The two-factor authentication code provided is incorrect.`,
|
||||
)
|
||||
.otherwise(() => handleFallbackErrorMessages(error.code));
|
||||
|
||||
@@ -368,7 +368,7 @@ export const SignInForm = ({
|
||||
<p className="mt-2 text-right">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
||||
className="text-sm text-muted-foreground duration-200 hover:opacity-70"
|
||||
>
|
||||
<Trans>Forgot your password?</Trans>
|
||||
</Link>
|
||||
@@ -390,11 +390,11 @@ export const SignInForm = ({
|
||||
<>
|
||||
{hasSocialAuthEnabled && (
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="bg-transparent text-muted-foreground">
|
||||
<Trans>Or continue with</Trans>
|
||||
</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -403,7 +403,7 @@ export const SignInForm = ({
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithGoogleClick}
|
||||
>
|
||||
@@ -417,7 +417,7 @@ export const SignInForm = ({
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithMicrosoftClick}
|
||||
>
|
||||
@@ -435,7 +435,7 @@ export const SignInForm = ({
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="bg-background text-muted-foreground border"
|
||||
className="border bg-background text-muted-foreground"
|
||||
disabled={isSubmitting}
|
||||
onClick={onSignInWithOIDCClick}
|
||||
>
|
||||
@@ -452,7 +452,7 @@ export const SignInForm = ({
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
loading={isPasskeyLoading}
|
||||
className="bg-background text-muted-foreground border"
|
||||
className="border bg-background text-muted-foreground"
|
||||
onClick={onSignInWithPasskey}
|
||||
>
|
||||
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
|
||||
|
||||
@@ -131,7 +131,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||
const errorMessage = match(error.code)
|
||||
.with(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
() => msg`You do not have permission to create a token for this team`,
|
||||
() => msg`You do not have permission to create a token for this team.`,
|
||||
)
|
||||
.otherwise(() => msg`Something went wrong. Please try again later.`);
|
||||
|
||||
@@ -212,12 +212,12 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel className="text-muted-foreground mt-2">
|
||||
<FormLabel className="mt-2 text-muted-foreground">
|
||||
<Trans>Never expire</Trans>
|
||||
</FormLabel>
|
||||
<div className="block md:py-1.5">
|
||||
<Switch
|
||||
className="bg-background mt-2"
|
||||
className="mt-2 bg-background"
|
||||
checked={noExpirationDate}
|
||||
onCheckedChange={setNoExpirationDate}
|
||||
/>
|
||||
@@ -254,14 +254,14 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||
>
|
||||
<Card gradient>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
Your token was created successfully! Make sure to copy it because you won't be
|
||||
able to see it again!
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="bg-muted-foreground/10 my-4 rounded-md px-2.5 py-1 font-mono text-sm">
|
||||
<p className="my-4 rounded-md bg-muted-foreground/10 px-2.5 py-1 font-mono text-sm">
|
||||
{newlyCreatedToken.token}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
|
||||
import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
|
||||
|
||||
type DefaultRecipientsMultiSelectComboboxProps = {
|
||||
listValues: TDefaultRecipient[];
|
||||
onChange: (_values: TDefaultRecipient[]) => void;
|
||||
teamId?: number;
|
||||
organisationId?: string;
|
||||
};
|
||||
|
||||
export const DefaultRecipientsMultiSelectCombobox = ({
|
||||
listValues,
|
||||
onChange,
|
||||
teamId,
|
||||
organisationId,
|
||||
}: DefaultRecipientsMultiSelectComboboxProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { data: organisationData, isLoading: isLoadingOrganisation } =
|
||||
trpc.organisation.member.find.useQuery(
|
||||
{
|
||||
organisationId: organisationId!,
|
||||
query: '',
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
},
|
||||
{
|
||||
enabled: !!organisationId,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: teamData, isLoading: isLoadingTeam } = trpc.team.member.find.useQuery(
|
||||
{
|
||||
teamId: teamId!,
|
||||
query: '',
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
},
|
||||
{
|
||||
enabled: !!teamId,
|
||||
},
|
||||
);
|
||||
|
||||
const members = organisationId ? organisationData?.data : teamData?.data;
|
||||
const isLoading = organisationId ? isLoadingOrganisation : isLoadingTeam;
|
||||
|
||||
const options = members?.map((member) => ({
|
||||
value: member.email,
|
||||
label: member.name ? `${member.name} (${member.email})` : member.email,
|
||||
}));
|
||||
|
||||
const value = listValues.map((recipient) => ({
|
||||
value: recipient.email,
|
||||
label: recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email,
|
||||
}));
|
||||
|
||||
const onSelectionChange = (selected: Option[]) => {
|
||||
const updatedRecipients = selected.map((option) => {
|
||||
const existingRecipient = listValues.find((r) => r.email === option.value);
|
||||
const member = members?.find((m) => m.email === option.value);
|
||||
|
||||
return {
|
||||
email: option.value,
|
||||
name: member?.name || option.value,
|
||||
role: existingRecipient?.role ?? RecipientRole.CC,
|
||||
};
|
||||
});
|
||||
|
||||
onChange(updatedRecipients);
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
commandProps={{ label: _(msg`Select recipients`) }}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onSelectionChange}
|
||||
placeholder={_(msg`Select recipients`)}
|
||||
hideClearAllButton
|
||||
hidePlaceholderWhenSelected
|
||||
loadingIndicator={isLoading ? <p className="text-center text-sm">Loading...</p> : undefined}
|
||||
emptyIndicator={<p className="text-center text-sm">No members found</p>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,4 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import type { Field } from '@prisma/client';
|
||||
@@ -57,8 +55,6 @@ export const DirectTemplateConfigureForm = ({
|
||||
initialEmail,
|
||||
onSubmit,
|
||||
}: DirectTemplateConfigureFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
|
||||
@@ -77,17 +73,7 @@ export const DirectTemplateConfigureForm = ({
|
||||
});
|
||||
|
||||
const form = useForm<TDirectTemplateConfigureFormSchema>({
|
||||
resolver: zodResolver(
|
||||
ZDirectTemplateConfigureFormSchema.superRefine((items, ctx) => {
|
||||
if (template.recipients.map((recipient) => recipient.email).includes(items.email)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: _(msg`Email cannot already exist in the template`),
|
||||
path: ['email'],
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
resolver: zodResolver(ZDirectTemplateConfigureFormSchema),
|
||||
defaultValues: {
|
||||
email: initialEmail || '',
|
||||
},
|
||||
@@ -138,7 +124,7 @@ export const DirectTemplateConfigureForm = ({
|
||||
</FormControl>
|
||||
|
||||
{!fieldState.error && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Enter your email address to receive the completed document.</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -417,6 +417,7 @@ export const DirectTemplateSigningForm = ({
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
fullName={fullName}
|
||||
value={signature ?? ''}
|
||||
onChange={(value) => setSignature(value)}
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
@@ -433,7 +434,7 @@ export const DirectTemplateSigningForm = ({
|
||||
|
||||
<div className="mt-4 flex gap-x-4">
|
||||
<Button
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -90,7 +90,7 @@ export const DocumentSigningAuthPasskey = ({
|
||||
preferredPasskeyId: passkeyId,
|
||||
});
|
||||
|
||||
const authenticationResponse = await startAuthentication(options);
|
||||
const authenticationResponse = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.PASSKEY,
|
||||
@@ -146,7 +146,7 @@ export const DocumentSigningAuthPasskey = ({
|
||||
if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) {
|
||||
return (
|
||||
<div className="flex h-28 items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useId, useMemo, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
||||
import { type Field, type Recipient, RecipientRole } from '@prisma/client';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-
|
||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -78,7 +79,7 @@ export const DocumentSigningForm = ({
|
||||
[fields],
|
||||
);
|
||||
|
||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
|
||||
|
||||
const uninsertedFields = useMemo(() => {
|
||||
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
|
||||
@@ -280,6 +281,7 @@ export const DocumentSigningForm = ({
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
fullName={fullName}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
|
||||
@@ -35,7 +35,7 @@ export const DocumentSigningMobileWidget = () => {
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
|
||||
<div className="pointer-events-auto w-full max-w-[760px]">
|
||||
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
|
||||
{/* Main Header Bar */}
|
||||
<div className="flex items-center justify-between gap-4 p-4">
|
||||
<div className="flex-1">
|
||||
@@ -48,15 +48,15 @@ export const DocumentSigningMobileWidget = () => {
|
||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||
<LucideChevronDown className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<LucideChevronUp className="text-muted-foreground h-5 w-5 flex-shrink-0" />
|
||||
<LucideChevronUp className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="text-foreground text-lg font-semibold">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
@@ -65,7 +65,7 @@ export const DocumentSigningMobileWidget = () => {
|
||||
.otherwise(() => null)}
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground -mt-0.5 text-sm">
|
||||
<p className="-mt-0.5 text-sm text-muted-foreground">
|
||||
{recipientFieldsRemaining.length === 0 ? (
|
||||
match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
@@ -102,11 +102,11 @@ export const DocumentSigningMobileWidget = () => {
|
||||
{recipient.role !== RecipientRole.VIEWER &&
|
||||
recipient.role !== RecipientRole.ASSISTANT && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="bg-muted relative h-[4px] rounded-md">
|
||||
<div className="relative h-[4px] rounded-md bg-muted">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-signing-mobile-widget-progress-bar"
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
className="absolute inset-y-0 left-0 bg-primary"
|
||||
style={{
|
||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
@@ -117,11 +117,11 @@ export const DocumentSigningMobileWidget = () => {
|
||||
|
||||
{/* Expandable Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
|
||||
<div className="border-t border-border p-4 duration-200 animate-in slide-in-from-bottom-2">
|
||||
<EnvelopeSignerForm />
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground mt-2 inline-block rounded px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:hidden">
|
||||
<div className="mt-2 inline-block rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:hidden">
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="absolute inset-y-0 left-0 bg-documenso"
|
||||
className="absolute inset-y-0 left-0 bg-primary"
|
||||
style={{
|
||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
|
||||
@@ -56,8 +56,11 @@ export const DocumentSigningSignatureField = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fontSize, setFontSize] = useState(2);
|
||||
|
||||
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
||||
useRequiredDocumentSigningContext();
|
||||
const {
|
||||
fullName,
|
||||
signature: providedSignature,
|
||||
setSignature: setProvidedSignature,
|
||||
} = useRequiredDocumentSigningContext();
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
@@ -236,13 +239,13 @@ export const DocumentSigningSignatureField = ({
|
||||
type="Signature"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-background">
|
||||
<Loader className="h-5 w-5 animate-spin text-primary md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'empty' && (
|
||||
<p className="group-hover:text-primary font-signature text-muted-foreground group-hover:text-recipient-green text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200">
|
||||
<p className="font-signature text-[clamp(0.575rem,25cqw,1.2rem)] text-xl text-muted-foreground duration-200 group-hover:text-primary group-hover:text-recipient-green">
|
||||
<Trans>Signature</Trans>
|
||||
</p>
|
||||
)}
|
||||
@@ -259,7 +262,7 @@ export const DocumentSigningSignatureField = ({
|
||||
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
|
||||
<p
|
||||
ref={signatureRef}
|
||||
className="font-signature text-muted-foreground w-full overflow-hidden break-all text-center leading-tight duration-200"
|
||||
className="w-full overflow-hidden break-all text-center font-signature leading-tight text-muted-foreground duration-200"
|
||||
style={{ fontSize: `${fontSize}rem` }}
|
||||
>
|
||||
{signature?.typedSignature}
|
||||
@@ -272,12 +275,13 @@ export const DocumentSigningSignatureField = ({
|
||||
<DialogTitle>
|
||||
<Trans>
|
||||
Sign as {recipient.name}{' '}
|
||||
<div className="text-muted-foreground h-5">({recipient.email})</div>
|
||||
<div className="h-5 text-muted-foreground">({recipient.email})</div>
|
||||
</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<SignaturePad
|
||||
className="mt-2"
|
||||
fullName={fullName}
|
||||
value={localSignature ?? ''}
|
||||
onChange={({ value }) => setLocalSignature(value)}
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const DocumentPageViewRecentActivity = ({
|
||||
documentId,
|
||||
userId,
|
||||
}: DocumentPageViewRecentActivityProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -48,9 +48,9 @@ export const DocumentPageViewRecentActivity = ({
|
||||
const documentAuditLogs = useMemo(() => (data?.pages ?? []).flatMap((page) => page.data), [data]);
|
||||
|
||||
return (
|
||||
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
|
||||
<section className="flex flex-col rounded-xl border border-border bg-widget dark:bg-background">
|
||||
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
|
||||
<h1 className="text-foreground font-medium">
|
||||
<h1 className="font-medium text-foreground">
|
||||
<Trans>Recent activity</Trans>
|
||||
</h1>
|
||||
|
||||
@@ -59,18 +59,18 @@ export const DocumentPageViewRecentActivity = ({
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex h-full items-center justify-center py-16">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoadingError && (
|
||||
<div className="flex h-full flex-col items-center justify-center py-16">
|
||||
<p className="text-foreground/80 text-sm">
|
||||
<p className="text-sm text-foreground/80">
|
||||
<Trans>Unable to load document history</Trans>
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => refetch()}
|
||||
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
|
||||
className="mt-2 text-sm text-foreground/70 hover:text-muted-foreground"
|
||||
>
|
||||
<Trans>Click here to retry</Trans>
|
||||
</button>
|
||||
@@ -83,16 +83,16 @@ export const DocumentPageViewRecentActivity = ({
|
||||
{hasNextPage && (
|
||||
<li className="relative flex gap-x-4">
|
||||
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
|
||||
<div className="bg-border w-px" />
|
||||
<div className="w-px bg-border" />
|
||||
</div>
|
||||
|
||||
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
|
||||
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={async () => fetchNextPage()}
|
||||
className="text-foreground/70 hover:text-muted-foreground text-xs"
|
||||
className="text-xs text-foreground/70 hover:text-muted-foreground"
|
||||
>
|
||||
{isFetchingNextPage ? _(msg`Loading...`) : _(msg`Load older activity`)}
|
||||
</button>
|
||||
@@ -101,7 +101,7 @@ export const DocumentPageViewRecentActivity = ({
|
||||
|
||||
{documentAuditLogs.length === 0 && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<p className="text-muted-foreground/70 text-sm">
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
<Trans>No recent activity</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -115,44 +115,44 @@ export const DocumentPageViewRecentActivity = ({
|
||||
'absolute left-0 top-0 flex w-6 justify-center',
|
||||
)}
|
||||
>
|
||||
<div className="bg-border w-px" />
|
||||
<div className="w-px bg-border" />
|
||||
</div>
|
||||
|
||||
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
|
||||
<div className="relative flex h-6 w-6 flex-none items-center justify-center bg-widget text-foreground/40">
|
||||
{match(auditLog.type)
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
|
||||
<CheckCheckIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
|
||||
<CheckIcon className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED, () => (
|
||||
<div className="bg-widget rounded-full border border-gray-300 p-1 dark:border-neutral-600">
|
||||
<div className="rounded-full border border-gray-300 bg-widget p-1 dark:border-neutral-600">
|
||||
<MailOpen className="h-3 w-3" aria-hidden="true" />
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-widget ring-1 ring-gray-300 dark:ring-neutral-600" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
||||
title={formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||
className="flex-auto truncate py-0.5 text-xs leading-5 text-muted-foreground dark:text-muted-foreground/70"
|
||||
title={formatDocumentAuditLogAction(i18n, auditLog, userId).description}
|
||||
>
|
||||
{formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||
{formatDocumentAuditLogAction(i18n, auditLog, userId).description}
|
||||
</p>
|
||||
|
||||
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
|
||||
<time className="flex-none py-0.5 text-xs leading-5 text-muted-foreground dark:text-muted-foreground/70">
|
||||
{DateTime.fromJSDate(auditLog.createdAt).toRelative({ style: 'short' })}
|
||||
</time>
|
||||
</li>
|
||||
|
||||
@@ -14,9 +14,10 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
||||
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
|
||||
import {
|
||||
@@ -31,9 +32,13 @@ import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentUploadButtonLegacyProps = {
|
||||
className?: string;
|
||||
type: EnvelopeType;
|
||||
};
|
||||
|
||||
export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLegacyProps) => {
|
||||
export const DocumentUploadButtonLegacy = ({
|
||||
className,
|
||||
type,
|
||||
}: DocumentUploadButtonLegacyProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
@@ -54,8 +59,18 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
|
||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||
|
||||
const disabledMessage = useMemo(() => {
|
||||
if (!user.emailVerified) {
|
||||
return msg`Verify your email to upload documents.`;
|
||||
}
|
||||
|
||||
// No errors for templates.
|
||||
if (type === EnvelopeType.TEMPLATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (organisation.subscription && remaining.documents === 0) {
|
||||
return msg`Document upload disabled due to unpaid invoices`;
|
||||
}
|
||||
@@ -64,11 +79,8 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
||||
return msg`You have reached your document limit.`;
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
return msg`Verify your email to upload documents.`;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [remaining.documents, user.emailVerified, team]);
|
||||
}, [remaining.documents, user.emailVerified, team, type]);
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
@@ -80,44 +92,62 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
||||
meta: {
|
||||
timezone: userTimezone,
|
||||
},
|
||||
} satisfies TCreateDocumentPayloadSchema;
|
||||
} satisfies TCreateDocumentPayloadSchema | TCreateTemplatePayloadSchema;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('file', file);
|
||||
|
||||
const { envelopeId: id } = await createDocument(formData);
|
||||
// Handle legacy document creation.
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
const { envelopeId: id } = await createDocument(formData);
|
||||
|
||||
void refreshLimits();
|
||||
void refreshLimits();
|
||||
|
||||
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
|
||||
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
|
||||
|
||||
toast({
|
||||
title: _(msg`Document uploaded`),
|
||||
description: _(msg`Your document has been uploaded successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
toast({
|
||||
title: _(msg`Document uploaded`),
|
||||
description: _(msg`Your document has been uploaded successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
analytics.capture('App: Document Uploaded', {
|
||||
userId: user.id,
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
analytics.capture('App: Document Uploaded', {
|
||||
userId: user.id,
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Handle legacy template creation.
|
||||
if (type === EnvelopeType.TEMPLATE) {
|
||||
const { envelopeId: id } = await createTemplate(formData);
|
||||
|
||||
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
||||
|
||||
toast({
|
||||
title: _(msg`Template document uploaded`),
|
||||
description: _(
|
||||
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
|
||||
),
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
|
||||
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs.`)
|
||||
.with(
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.with(
|
||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||
() => msg`You have reached the limit of the number of files per envelope`,
|
||||
() => msg`You have reached the limit of the number of files per envelope.`,
|
||||
)
|
||||
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||
|
||||
@@ -149,17 +179,18 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
||||
<div>
|
||||
<DocumentUploadButtonPrimitive
|
||||
loading={isLoading}
|
||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||
disabled={disabledMessage !== undefined}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={async (files) => onFileDrop(files[0])}
|
||||
onDropRejected={onFileDropRejected}
|
||||
type={EnvelopeType.DOCUMENT}
|
||||
type={type}
|
||||
internalVersion="1"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
{team?.id === undefined &&
|
||||
type === EnvelopeType.DOCUMENT &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<TooltipContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
DragDropContext,
|
||||
@@ -7,28 +7,23 @@ import {
|
||||
Droppable,
|
||||
type SensorAPI,
|
||||
} from '@hello-pangea/dnd';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { useFieldArray, useWatch } from 'react-hook-form';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { ZEditorRecipientsFormSchema } from '@documenso/lib/client-only/hooks/use-editor-recipients';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import {
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -67,26 +62,9 @@ import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-
|
||||
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZEnvelopeRecipientsForm = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
id: z.number().optional(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).optional().default([]),
|
||||
}),
|
||||
),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type TEnvelopeRecipientsForm = z.infer<typeof ZEnvelopeRecipientsForm>;
|
||||
|
||||
export const EnvelopeEditorRecipientForm = () => {
|
||||
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
||||
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients } =
|
||||
useCurrentEnvelopeEditor();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
@@ -145,7 +123,6 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const initialId = useId();
|
||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
const { recipients, fields } = envelope;
|
||||
@@ -161,42 +138,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
const recipientSuggestions = recipientSuggestionsData?.results || [];
|
||||
|
||||
const defaultRecipients = [
|
||||
{
|
||||
formId: initialId,
|
||||
name: '',
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
actionAuth: [],
|
||||
},
|
||||
];
|
||||
|
||||
const form = useForm<TEnvelopeRecipientsForm>({
|
||||
resolver: zodResolver(ZEnvelopeRecipientsForm),
|
||||
mode: 'onChange', // Used for autosave purposes, maybe can try onBlur instead?
|
||||
defaultValues: {
|
||||
signers:
|
||||
recipients.length > 0
|
||||
? sortBy(
|
||||
recipients.map((recipient, index) => ({
|
||||
id: recipient.id,
|
||||
formId: String(recipient.id),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder ?? index + 1,
|
||||
actionAuth:
|
||||
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||
})),
|
||||
[prop('signingOrder'), 'asc'],
|
||||
[prop('id'), 'asc'],
|
||||
)
|
||||
: defaultRecipients,
|
||||
signingOrder: envelope.documentMeta.signingOrder,
|
||||
allowDictateNextSigner: envelope.documentMeta.allowDictateNextSigner,
|
||||
},
|
||||
});
|
||||
const { form } = editorRecipients;
|
||||
|
||||
const recipientHasAuthSettings = useMemo(() => {
|
||||
const recipientHasAuthOptions = recipients.find((recipient) => {
|
||||
@@ -588,7 +530,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
|
||||
const validatedFormValues = ZEditorRecipientsFormSchema.safeParse(formValues);
|
||||
|
||||
if (!validatedFormValues.success) {
|
||||
return;
|
||||
@@ -848,246 +790,205 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
ref={provided.innerRef}
|
||||
className="flex w-full flex-col gap-y-2"
|
||||
>
|
||||
{signers.map((signer, index) => (
|
||||
<Draggable
|
||||
key={`${signer.nativeId}-${signer.signingOrder}`}
|
||||
draggableId={signer['nativeId']}
|
||||
index={index}
|
||||
isDragDisabled={
|
||||
!isSigningOrderSequential ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
!signer.signingOrder
|
||||
}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={cn('py-1', {
|
||||
'pointer-events-none rounded-md bg-widget-foreground pt-2':
|
||||
snapshot.isDragging,
|
||||
})}
|
||||
>
|
||||
<motion.fieldset
|
||||
data-native-id={signer.id}
|
||||
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
|
||||
className={cn('pb-2', {
|
||||
'border-b pb-4':
|
||||
showAdvancedSettings && index !== signers.length - 1,
|
||||
'pt-2': showAdvancedSettings && index === 0,
|
||||
'pr-3': isSigningOrderSequential,
|
||||
{signers.map((signer, index) => {
|
||||
const isDirectRecipient =
|
||||
envelope.type === EnvelopeType.TEMPLATE &&
|
||||
envelope.directLink !== null &&
|
||||
signer.id === envelope.directLink.directTemplateRecipientId;
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={`${signer.nativeId}-${signer.signingOrder}`}
|
||||
draggableId={signer['nativeId']}
|
||||
index={index}
|
||||
isDragDisabled={
|
||||
!isSigningOrderSequential ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
!signer.signingOrder
|
||||
}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={cn('py-1', {
|
||||
'pointer-events-none rounded-md bg-widget-foreground pt-2':
|
||||
snapshot.isDragging,
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
{isSigningOrderSequential && (
|
||||
<motion.fieldset
|
||||
data-native-id={signer.id}
|
||||
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
|
||||
className={cn('pb-2', {
|
||||
'border-b pb-4':
|
||||
showAdvancedSettings && index !== signers.length - 1,
|
||||
'pt-2': showAdvancedSettings && index === 0,
|
||||
'pr-3': isSigningOrderSequential,
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
{isSigningOrderSequential && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.signingOrder`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn(
|
||||
'mt-auto flex items-center gap-x-1 space-y-0',
|
||||
{
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.signingOrder,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
max={signers.length}
|
||||
data-testid="signing-order-input"
|
||||
className={cn(
|
||||
'w-10 text-center',
|
||||
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
||||
)}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
handleSigningOrderChange(index, e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onBlur();
|
||||
handleSigningOrderChange(index, e.target.value);
|
||||
}}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.signingOrder`}
|
||||
name={`signers.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn(
|
||||
'mt-auto flex items-center gap-x-1 space-y-0',
|
||||
{
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.signingOrder,
|
||||
},
|
||||
)}
|
||||
className={cn('relative w-full', {
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.email,
|
||||
})}
|
||||
>
|
||||
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
|
||||
{!showAdvancedSettings && index === 0 && (
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
max={signers.length}
|
||||
data-testid="signing-order-input"
|
||||
className={cn(
|
||||
'w-10 text-center',
|
||||
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
|
||||
)}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
handleSigningOrderChange(index, e.target.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onBlur();
|
||||
handleSigningOrderChange(index, e.target.value);
|
||||
}}
|
||||
<RecipientAutoCompleteInput
|
||||
type="email"
|
||||
placeholder={t`Email`}
|
||||
value={field.value}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
isDirectRecipient
|
||||
}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||
}
|
||||
onSearchQueryChange={(query) => {
|
||||
field.onChange(query);
|
||||
setRecipientSearchQuery(query);
|
||||
}}
|
||||
loading={isLoading}
|
||||
data-testid="signer-email-input"
|
||||
maxLength={254}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.email`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn('relative w-full', {
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.email,
|
||||
})}
|
||||
>
|
||||
{!showAdvancedSettings && index === 0 && (
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<RecipientAutoCompleteInput
|
||||
type="email"
|
||||
placeholder={t`Email`}
|
||||
value={field.value}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||
}
|
||||
onSearchQueryChange={(query) => {
|
||||
field.onChange(query);
|
||||
setRecipientSearchQuery(query);
|
||||
}}
|
||||
loading={isLoading}
|
||||
data-testid="signer-email-input"
|
||||
maxLength={254}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn('w-full', {
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.name,
|
||||
})}
|
||||
>
|
||||
{!showAdvancedSettings && index === 0 && (
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<RecipientAutoCompleteInput
|
||||
type="text"
|
||||
placeholder={t`Recipient ${index + 1}`}
|
||||
{...field}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||
}
|
||||
onSearchQueryChange={(query) => {
|
||||
field.onChange(query);
|
||||
setRecipientSearchQuery(query);
|
||||
}}
|
||||
loading={isLoading}
|
||||
maxLength={255}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn('mt-auto w-fit', {
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.role,
|
||||
})}
|
||||
>
|
||||
<FormControl>
|
||||
<RecipientRoleSelect
|
||||
{...field}
|
||||
isAssistantEnabled={isSigningOrderSequential}
|
||||
onValueChange={(value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
handleRoleChange(index, value as RecipientRole);
|
||||
field.onChange(value);
|
||||
}}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('mt-auto px-2', {
|
||||
'mb-6': form.formState.errors.signers?.[index],
|
||||
})}
|
||||
data-testid="remove-signer-button"
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
signers.length === 1
|
||||
}
|
||||
onClick={() => onRemoveSigner(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showAdvancedSettings &&
|
||||
organisation.organisationClaim.flags.cfr21 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.actionAuth`}
|
||||
name={`signers.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn('mt-2 w-full', {
|
||||
className={cn('w-full', {
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.actionAuth,
|
||||
'pl-6': isSigningOrderSequential,
|
||||
!form.formState.errors.signers[index]?.name,
|
||||
})}
|
||||
>
|
||||
{!showAdvancedSettings && index === 0 && (
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<RecipientAutoCompleteInput
|
||||
type="text"
|
||||
placeholder={t`Recipient ${index + 1}`}
|
||||
{...field}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
isDirectRecipient
|
||||
}
|
||||
options={recipientSuggestions}
|
||||
onSelect={(suggestion) =>
|
||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||
}
|
||||
onSearchQueryChange={(query) => {
|
||||
field.onChange(query);
|
||||
setRecipientSearchQuery(query);
|
||||
}}
|
||||
loading={isLoading}
|
||||
maxLength={255}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn('mt-auto w-fit', {
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.role,
|
||||
})}
|
||||
>
|
||||
<FormControl>
|
||||
<RecipientActionAuthSelect
|
||||
<RecipientRoleSelect
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
isAssistantEnabled={isSigningOrderSequential}
|
||||
onValueChange={(value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
handleRoleChange(index, value as RecipientRole);
|
||||
field.onChange(value);
|
||||
}}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
@@ -1100,12 +1001,63 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</motion.fieldset>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('mt-auto px-2', {
|
||||
'mb-6': form.formState.errors.signers?.[index],
|
||||
})}
|
||||
data-testid="remove-signer-button"
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id) ||
|
||||
signers.length === 1 ||
|
||||
isDirectRecipient
|
||||
}
|
||||
onClick={() => onRemoveSigner(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showAdvancedSettings &&
|
||||
organisation.organisationClaim.flags.cfr21 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`signers.${index}.actionAuth`}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={cn('mt-2 w-full', {
|
||||
'mb-6':
|
||||
form.formState.errors.signers?.[index] &&
|
||||
!form.formState.errors.signers[index]?.actionAuth,
|
||||
'pl-6': isSigningOrderSequential,
|
||||
})}
|
||||
>
|
||||
<FormControl>
|
||||
<RecipientActionAuthSelect
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.id)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</motion.fieldset>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
|
||||
@@ -339,9 +339,11 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
|
||||
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
|
||||
{/* Sidebar. */}
|
||||
<div className="bg-accent/20 flex w-80 flex-col border-r">
|
||||
<div className="flex w-80 flex-col border-r bg-accent/20">
|
||||
<DialogHeader className="p-6 pb-4">
|
||||
<DialogTitle>Document Settings</DialogTitle>
|
||||
<DialogTitle>
|
||||
<Trans>Document Settings</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<nav className="col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 px-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2">
|
||||
@@ -390,7 +392,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||
<Trans>
|
||||
Controls the language for the document, including the language
|
||||
to be used for email notifications, and the final certificate
|
||||
@@ -441,7 +443,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
className="bg-background w-full"
|
||||
className="w-full bg-background"
|
||||
emptySelectionPlaceholder="Select signature types"
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -518,7 +520,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Add an external ID to the document. This can be used to identify
|
||||
the document in external systems.
|
||||
@@ -548,7 +550,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Add a URL to redirect the user to once the document is signed
|
||||
</Trans>
|
||||
@@ -576,7 +578,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Document Distribution Method</Trans>
|
||||
@@ -687,8 +689,10 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
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>
|
||||
@@ -726,20 +730,21 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
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>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground p-4">
|
||||
<TooltipContent className="p-4 text-muted-foreground">
|
||||
<DocumentSendEmailMessageHelper />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="bg-background h-16 resize-none" {...field} />
|
||||
<Textarea className="h-16 resize-none bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -306,7 +306,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={provided.draggableProps.style}
|
||||
className={`bg-accent/50 flex items-center justify-between rounded-lg p-3 transition-shadow ${
|
||||
className={`flex items-center justify-between rounded-lg bg-accent/50 p-3 transition-shadow ${
|
||||
snapshot.isDragging ? 'shadow-md' : ''
|
||||
}`}
|
||||
>
|
||||
@@ -332,7 +332,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
<p className="text-sm font-medium">{localFile.title}</p>
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{localFile.isUploading ? (
|
||||
<Trans>Uploading</Trans>
|
||||
) : localFile.isError ? (
|
||||
@@ -345,13 +345,13 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
<div className="flex items-center space-x-2">
|
||||
{localFile.isUploading && (
|
||||
<div className="flex h-6 w-10 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localFile.isError && (
|
||||
<div className="flex h-6 w-10 items-center justify-center">
|
||||
<FileWarningIcon className="text-destructive h-4 w-4" />
|
||||
<FileWarningIcon className="h-4 w-4 text-destructive" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||