Compare commits

..

24 Commits

Author SHA1 Message Date
Lucas Smith 1b2a500591 fix: uncook the lock file 2025-11-30 13:49:24 +11:00
Lucas Smith b499feb609 Merge branch 'main' into exp/autoplace-fields 2025-11-28 12:30:16 +11:00
Ephraim Atta-Duncan adbda9caf5 fix: merge conflicts 2025-11-27 03:19:15 +00:00
Ephraim Atta-Duncan 654fc57639 chore: review 2025-11-19 10:57:54 +00:00
Ephraim Atta-Duncan a0a3e7fb93 chore: review 2025-11-19 08:53:57 +00:00
Ephraim Atta-Duncan 57c4c3fd48 chore: remove unused stuff 2025-11-19 01:51:04 +00:00
Ephraim Atta-Duncan eb63ec436c chore: clean 2025-11-19 01:08:49 +00:00
Ephraim Duncan d6a1ce06d4 Merge branch 'main' into exp/autoplace-fields 2025-11-19 00:44:51 +00:00
Ephraim Atta-Duncan 08e1dcaa76 chore: translations 2025-11-19 00:44:31 +00:00
Ephraim Atta-Duncan 92ec5e8ee4 feat: detect fields 2025-11-19 00:40:22 +00:00
Ephraim Atta-Duncan 548a74ab89 chore: improve detection prompts 2025-11-18 21:53:36 +00:00
Ephraim Atta-Duncan c8e254aff1 chore: remove duplicates 2025-11-18 21:05:37 +00:00
Ephraim Atta-Duncan 8e2ca94020 chore: refactor 2025-11-18 20:07:04 +00:00
Ephraim Atta-Duncan 13bd5815d9 fix: email place holder 2025-11-18 17:59:36 +00:00
Ephraim Atta-Duncan dbed8b362e feat: generate recipients and fields based on recipients 2025-11-17 01:44:22 +00:00
Ephraim Atta-Duncan 9e0f07f806 chore: prompt changes 2025-11-12 23:32:54 +00:00
Ephraim Atta-Duncan 5fbad9e367 fix: merge conflicts 2025-11-12 12:26:14 +00:00
Ephraim Atta-Duncan ac4b3737d6 feat: convert AI field placement to server-side processing 2025-11-06 01:43:04 +00:00
Ephraim Atta-Duncan cdfd373958 feat: add glow animation 2025-10-31 06:03:38 +00:00
Ephraim Atta-Duncan 233e6e603c feat: multiple pages 2025-10-31 03:27:37 +00:00
Ephraim Atta-Duncan 134d5ac03e chore: refactor 2025-10-30 12:20:52 +00:00
Ephraim Atta-Duncan 00e33c5331 chore: cleanup 2025-10-30 00:33:59 +00:00
Ephraim Atta-Duncan 29be66a844 feat: add AI field auto-placement with canvas registry 2025-10-29 23:03:58 +00:00
Ephraim Atta-Duncan 94098bd762 feat: generates coordinates for bounding boxes 2025-10-29 18:14:16 +00:00
290 changed files with 7353 additions and 14555 deletions
+7 -8
View File
@@ -148,13 +148,12 @@ NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
DOCUMENSO_DISABLE_TELEMETRY=
# [[AI]]
# OPTIONAL: Google Cloud Project ID for Vertex AI.
GOOGLE_VERTEX_PROJECT_ID=""
# OPTIONAL: Google Cloud region for Vertex AI. Defaults to "global".
GOOGLE_VERTEX_LOCATION="global"
# OPTIONAL: API key for Google Vertex AI (Gemini). Get your key from:
# https://console.cloud.google.com/vertex-ai/studio/settings/api-keys
GOOGLE_VERTEX_API_KEY=""
# AI Gateway
AI_GATEWAY_API_KEY=""
# OPTIONAL: API key for Google Generative AI (Gemini). Get your key from https://ai.google.dev
GOOGLE_GENERATIVE_AI_API_KEY=""
# OPTIONAL: Enable AI field detection debug mode to save preview images with bounding boxes
NEXT_PUBLIC_AI_DEBUG_PREVIEW=
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
@@ -166,4 +165,4 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
NEXT_PRIVATE_LOGGER_FILE_PATH=
# [[PLAIN SUPPORT]]
NEXT_PRIVATE_PLAIN_API_KEY=
NEXT_PRIVATE_PLAIN_API_KEY=
+6 -8
View File
@@ -1,19 +1,14 @@
name: Playwright Tests
on:
push:
branches: ['main']
branches: ['main', 'feat/rr7']
pull_request:
branches: ['main']
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e_tests:
name: 'E2E Tests'
timeout-minutes: 60
runs-on: warp-ubuntu-2204-x64-8x
runs-on: warp-ubuntu-2204-x64-16x
steps:
- uses: actions/checkout@v4
@@ -33,6 +28,9 @@ jobs:
- name: Seed the database
run: npm run prisma:seed
- name: Build app
run: npm run build
- name: Install playwright browsers
run: npx playwright install --with-deps
@@ -47,7 +45,7 @@ jobs:
with:
name: test-results
path: 'packages/app-tests/**/test-results/*'
retention-days: 7
retention-days: 30
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
+2 -45
View File
@@ -17,7 +17,6 @@ jobs:
environment: Translations
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -27,54 +26,12 @@ jobs:
- name: Extract translations
run: npm run translate:extract
- name: Commit changes and push to reserved branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check and commit any files created
run: |
set -euo pipefail
BRANCH="chore/extract-translations"
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@documenso.com'
git fetch origin
# Create branch locally (always reset to main)
git checkout -B "$BRANCH" origin/main
# Stage translation output
git add packages/lib/translations
# If no changes, exit early
if git diff --staged --quiet; then
echo "No translation changes found."
exit 0
fi
# Commit fresh snapshot
git commit -m "chore: extract translations"
# Force push reserved branch
git push origin "$BRANCH" --force
# Does a PR already exist?
EXISTING_PR=$(gh pr list \
--state open \
--head "$BRANCH" \
--json number \
--jq '.[0].number // empty')
if [ -z "$EXISTING_PR" ]; then
echo "No existing PR — creating new one."
gh pr create \
--title "chore: extract translations" \
--body "Automated translation extraction" \
--base main \
--head "$BRANCH"
else
echo "PR #$EXISTING_PR already exists — not creating a new one."
fi
git diff --staged --quiet --exit-code || (git commit -m "chore: extract translations" && git push)
- name: Compile translations
id: compile_translations
+2 -2
View File
@@ -61,5 +61,5 @@ CLAUDE.md
# agents
.specs
# scripts
scripts/output*
# ai debug previews
packages/assets/ai-previews/
+5 -4
View File
@@ -1,3 +1,6 @@
> 🚨 🚨 🚨
> Documenso 2.0.0 is live on Product Hunt 🎉 <a href="https://documen.so/launch" target="_blank" rel="noopener noreferrer" style="text-decoration: underline;">Join us to celebrate the best Documenso yet 🪩</a>
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
<p align="center" style="margin-top: 20px">
@@ -171,11 +174,9 @@ git clone https://github.com/<your-username>/documenso
5. Create the database schema by running `npm run prisma:migrate-dev`
6. Run `npm run translate:compile` in the root directory to compile lingui
6. Run `npm run dev` in the root directory to start
7. Run `npm run dev` in the root directory to start
8. Register a new user at http://localhost:3000/signup
7. Register a new user at http://localhost:3000/signup
---
+2 -2
View File
@@ -15,7 +15,7 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "15.5.9",
"next": "^15",
"next-plausible": "^3.12.5",
"nextra": "^3",
"nextra-theme-docs": "^3",
@@ -29,4 +29,4 @@
"pagefind": "^1.2.0",
"typescript": "5.6.2"
}
}
}
@@ -4,5 +4,4 @@ export default {
'how-to': 'How To',
'setting-up-oauth-providers': 'Setting up OAuth Providers',
telemetry: 'Telemetry',
'ai-features': 'AI Recipient & Field Detection',
};
@@ -1,72 +0,0 @@
---
title: AI Recipient & Field Detection (Self-hosting)
description: Configure Google Vertex AI so Documenso can detect recipients and fields automatically.
---
import { Callout, Steps } from 'nextra/components';
# AI Recipient & Field Detection (Self-hosting)
This guide covers how to enable the AI recipient and field detection features when you self-host Documenso.
## What this enables
- Detect recipients from uploaded PDFs (roles, names, emails when present).
- Detect and place fields (signature, initials, name, email, date, text, number, radio, checkbox) onto draft envelopes.
- Built-in rate limits (3 requests per minute per IP) to prevent abuse.
## Prerequisites
- A Google Cloud project with the **Vertex AI API** enabled and billing active.
- A **Vertex AI Express API key** with access to Gemini models (create via the [Vertex AI Express flow](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview) and manage keys in [API keys](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys)).
- Documenso version that includes the AI detection feature and the corresponding database migration.
## Configure environment variables
Add these variables to your deployment `.env` (or secret manager):
```
GOOGLE_VERTEX_PROJECT_ID="<your-gcp-project-id>"
GOOGLE_VERTEX_API_KEY="<your-vertex-api-key>"
# Optional, defaults to "global"
GOOGLE_VERTEX_LOCATION="global"
```
<Callout type="info">
Use a region close to your users if you need data residency considerations (e.g. `europe-west1`).
If you omit the location, Documenso uses `global`. Not all models are available in every region;
if a model is unavailable, switch to a supported region.
</Callout>
## Deploy with the published container
- Use the official Documenso image (DockerHub or GHCR) and supply the Vertex env vars above.
- Ensure migrations run on startup (the container runs `prisma migrate deploy` in production mode).
- Restart the container after adding or changing Vertex env vars.
## Enable the feature in Documenso
Once the service is running with the Vertex env vars:
<Steps>
### Organisation settings
Go to **Settings → Document Preferences → AI Features** and set to **Enabled**. Teams that inherit organisation defaults will pick this up.
### Team settings
If a team overrides organisation defaults, go to **Team Settings → Document Preferences → AI Features** and choose **Enabled** (or **Inherit** to follow the organisation).
### Verify in the editor
Open a draft envelope. In **Recipients**, you should see the sparkle button for AI detection. In **Fields**, you should see **Detect with AI** available.
</Steps>
## Troubleshooting
- **Too many requests**: Wait a minute or two and retry (rate limit is 3/min per IP).
- **AI options hidden**: Ensure the env vars are set, the server was restarted after setting them, and `aiFeaturesEnabled` is enabled at organisation/team level.
- **Detection fails immediately**: Confirm the Vertex API key is valid and the project has Vertex AI enabled. Check server logs for status codes from Vertex.
If issues persist, recheck env vars, restart the service, and confirm the Prisma migration was applied.
@@ -119,8 +119,6 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
```
For full AI setup details (including model availability notes), see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
### Set Up Your Signing Certificate
<Callout type="warning">
@@ -269,63 +267,58 @@ You can access the Documenso application by visiting the URL you provided for th
The environment variables listed above are a subset of those available for configuring Documenso. The table below provides a complete list of environment variables and their descriptions.
For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
| Variable | Description |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
| `GOOGLE_VERTEX_PROJECT_ID` | Google Cloud project ID used for Vertex AI (required for AI detection). |
| `GOOGLE_VERTEX_API_KEY` | Vertex AI Express API key with access to Gemini models (required for AI detection). See [AI Recipient & Field Detectionfor](./ai-features) for details. |
| `GOOGLE_VERTEX_LOCATION` | Optional Vertex region, defaults to `global`. Not all models are available in every region. |
| Variable | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
## Telemetry
@@ -4,5 +4,4 @@ export default {
'document-visibility': 'Document Visibility',
fields: 'Document Fields',
'email-preferences': 'Email Preferences',
'ai-detection': 'AI Recipient & Field Detection',
};
@@ -1,68 +0,0 @@
---
title: AI Recipient & Field Detection
description: Use Documensos AI helpers to detect recipients and fields in draft documents.
---
# AI Recipient & Field Detection
Documenso can suggest recipients and place fields automatically using Google Vertex AI (Gemini). The feature is optional and only available when your organisation or team has **AI Features** enabled. Documents are processed securely and providers do not retain your data for training.
## Requirements
- AI Features must be enabled in **Document Preferences** for your organisation or team.
- The envelope must be in **Draft** status.
- Helpful rate limits are in place (up to 3 detection requests per minute per IP) to prevent abuse. If you see a “too many requests” message, wait a minute or two and try again.
### Enable AI features
1. **Organisation settings**:
Settings → Document Preferences → **AI Features** → Enabled.
_This applies to teams that inherit organisation defaults._
2. **Team settings**:
Team Settings → Document Preferences → **AI Features** → choose Enabled, Disabled, or Inherit.
## Detect recipients
Use this to identify who needs to sign or approve.
1. Open a draft document/template and go to the **Recipients** panel.
2. Select the **sparkle** button to start detection. If AI is enabled, uploads launched from the dashboard will open the detector automatically.
![Detect recipients with AI button in the Recipients panel](/document-signing/ai-recipient-detect-button.webp)
3. Wait for progress to finish, then review the suggested recipients.
4. Remove any incorrect entries, then **Add recipients** to apply them. Existing recipients and duplicates are preserved.
Notes:
- Detection is unavailable once an envelope is completed.
- You can re-run detection if you update the document; each run counts toward the rate limit.
## Detect fields
Use this to auto-place fields on the pages of a draft.
1. Open the envelope editor and switch to the **Fields** tab.
2. Select **Detect with AI**. Provide optional context (e.g., “Alice is the tenant, Bob is the landlord”) to improve recipient assignment.
![AI field detection dialog with context input](/document-signing/ai-field-detection-button.webp)
![AI field detection dialog with context input](/document-signing/ai-field-detection-dialog.webp)
3. Watch the progress indicators; they update per page and total fields found.
4. Review the summary and choose **Add fields** to place them in the editor.
Notes:
- Works only for draft envelopes and teams with AI features enabled.
- Existing fields are masked during detection to avoid duplicates.
- Fields are assigned to recipients based on nearby labels and your context message; you can edit them after adding.
## Best practices
- Keep labels near the intended fields (e.g., “Tenant signature”, “Buyer email”).
- Provide short context when roles are ambiguous.
- Always review suggestions before sending; AI assists but does not replace final checks.
@@ -178,7 +178,7 @@ The dropdown/select field collects a single choice from a list of options.
Place the dropdown/select field on the document where you want the signer to select a choice. The dropdown/select field comes with additional settings that can be configured.
![The dropdown/select field in the Documenso document editor](/document-signing/dropdown-field-document-editor-view.webp)
{/* ![The dropdown/select field in the Documenso document editor](/document-signing/dropdown-field-document-editor-view.webp) */}
The dropdown/select field settings include:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

+2 -2
View File
@@ -12,11 +12,11 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "15.5.9"
"next": "^15"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.3.27",
"typescript": "5.6.2"
}
}
}
+1
View File
@@ -69,3 +69,4 @@
--font-noto: 'Noto Sans', 'Noto Sans Korean', 'Noto Sans Japanese', 'Noto Sans Chinese';
}
}
@@ -45,7 +45,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
toast({
title: _(msg`Document deleted`),
description: _(msg`The Document has been deleted successfully.`),
description: 'The Document has been deleted successfully.',
duration: 5000,
});
@@ -54,9 +54,8 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to delete your document. Please try again later.`,
),
description:
'We encountered an unknown error while attempting to delete your document. Please try again later.',
});
}
};
@@ -156,8 +156,8 @@ export const AdminOrganisationMemberUpdateDialog = ({
<DialogDescription className="mt-4">
<Trans>
You are currently updating <span className="font-bold">{organisationMemberName}</span>
.
You are currently updating{' '}
<span className="font-bold">{organisationMemberName}.</span>
</Trans>
</DialogDescription>
</DialogHeader>
@@ -1,141 +0,0 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useCurrentTeam } from '~/providers/team';
type AiFeaturesEnableDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onEnabled: () => void;
};
export const AiFeaturesEnableDialog = ({
open,
onOpenChange,
onEnabled,
}: AiFeaturesEnableDialogProps) => {
const { t } = useLingui();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const isTeamAdmin = team.currentTeamRole === TeamMemberRole.ADMIN;
const isOrganisationAdmin = organisation.currentOrganisationRole === OrganisationMemberRole.ADMIN;
const canEnableAiFeatures = isTeamAdmin || isOrganisationAdmin;
const [error, setError] = useState<string | null>(null);
const { mutateAsync: updateTeamSettings, isPending: isUpdatingTeamSettings } =
trpc.team.settings.update.useMutation();
const { mutateAsync: updateOrganisationSettings, isPending: isUpdatingOrganisationSettings } =
trpc.organisation.settings.update.useMutation();
const isSubmitting = isUpdatingTeamSettings || isUpdatingOrganisationSettings;
const onEnableClick = async () => {
if (!canEnableAiFeatures) {
return;
}
setError(null);
try {
if (isTeamAdmin) {
await updateTeamSettings({
teamId: team.id,
data: { aiFeaturesEnabled: true },
});
} else {
await updateOrganisationSettings({
organisationId: organisation.id,
data: { aiFeaturesEnabled: true },
});
}
onEnabled();
onOpenChange(false);
} catch (err) {
console.error('Failed to enable AI features', err);
setError(
err instanceof Error
? err.message
: t`We couldn't enable AI features right now. Please try again.`,
);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
<Trans>Enable AI features</Trans>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>
Turn on AI detection to automatically find recipients and fields in your documents. AI
providers do not retain your data for training.
</Trans>
</p>
<Alert variant="neutral">
<AlertDescription>
<Trans>
Your document content will be sent securely to our AI provider solely for detection
and will not be stored or used for training.
</Trans>
</AlertDescription>
</Alert>
{canEnableAiFeatures ? (
<p className="text-sm text-muted-foreground">
<Trans>
You're an admin. You can enable AI features for this team right away. Everyone on
the team will see AI detection once enabled.
</Trans>
</p>
) : (
<p className="text-sm text-muted-foreground">
<Trans>
AI features are disabled for your team. Please ask your team owner or organisation
owner to enable them.
</Trans>
</p>
)}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
{canEnableAiFeatures ? (
<Button type="button" onClick={() => void onEnableClick()} loading={isSubmitting}>
<Trans>Enable AI features</Trans>
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,381 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import {
AiApiError,
type DetectFieldsProgressEvent,
detectFields,
} from '../../../server/api/ai/detect-fields.client';
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
type AiFieldDetectionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: (fields: NormalizedFieldWithContext[]) => void;
envelopeId: string;
teamId: number;
};
const PROCESSING_MESSAGES = [
msg`Reading your document`,
msg`Analyzing page layout`,
msg`Looking for form fields`,
msg`Detecting signature areas`,
msg`Identifying input fields`,
msg`Mapping fields to recipients`,
msg`Almost done`,
] as const;
const FIELD_TYPE_LABELS: Record<string, MessageDescriptor> = {
SIGNATURE: msg`Signature`,
INITIALS: msg`Initials`,
NAME: msg`Name`,
EMAIL: msg`Email`,
DATE: msg`Date`,
TEXT: msg`Text`,
NUMBER: msg`Number`,
CHECKBOX: msg`Checkbox`,
RADIO: msg`Radio`,
};
export const AiFieldDetectionDialog = ({
open,
onOpenChange,
onComplete,
envelopeId,
teamId,
}: AiFieldDetectionDialogProps) => {
const { _ } = useLingui();
const [state, setState] = useState<DialogState>('PROMPT');
const [messageIndex, setMessageIndex] = useState(0);
const [detectedFields, setDetectedFields] = useState<NormalizedFieldWithContext[]>([]);
const [error, setError] = useState<string | null>(null);
const [context, setContext] = useState('');
const [progress, setProgress] = useState<DetectFieldsProgressEvent | null>(null);
const onDetectClick = useCallback(async () => {
setState('PROCESSING');
setMessageIndex(0);
setError(null);
setProgress(null);
try {
await detectFields({
request: {
envelopeId,
teamId,
context: context || undefined,
},
onProgress: (progressEvent) => {
setProgress(progressEvent);
},
onComplete: (event) => {
setDetectedFields(event.fields);
setState('REVIEW');
},
onError: (err) => {
console.error('Detection failed:', err);
if (err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err.message);
setState('ERROR');
},
});
} catch (err) {
console.error('Detection failed:', err);
if (err instanceof AiApiError && err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err instanceof Error ? err.message : 'Failed to detect fields');
setState('ERROR');
}
}, [envelopeId, teamId, context]);
const onAddFields = () => {
onComplete(detectedFields);
onOpenChange(false);
setState('PROMPT');
setDetectedFields([]);
setContext('');
};
const onClose = () => {
onOpenChange(false);
setState('PROMPT');
setDetectedFields([]);
setError(null);
setContext('');
setProgress(null);
};
// Group fields by type for summary display
const fieldCountsByType = useMemo(() => {
const counts: Record<string, number> = {};
for (const field of detectedFields) {
counts[field.type] = (counts[field.type] || 0) + 1;
}
return Object.entries(counts).sort(([, a], [, b]) => b - a);
}, [detectedFields]);
useEffect(() => {
if (state !== 'PROCESSING') {
return;
}
const interval = setInterval(() => {
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
}, 4000);
return () => clearInterval(interval);
}, [state]);
return (
<Dialog open={open}>
<DialogContent className="sm:max-w-lg" hideClose={true}>
{state === 'PROMPT' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detect fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>
We'll scan your document to find form fields like signature lines, text inputs,
checkboxes, and more. Detected fields will be suggested for you to review.
</Trans>
</p>
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
<AlertDescription className="mt-0">
<Trans>
Your document is processed securely using AI services that don't retain your
data.
</Trans>
</AlertDescription>
</Alert>
<div className="space-y-1.5">
<Label htmlFor="context">
<Trans>Context</Trans>
</Label>
<Textarea
id="context"
placeholder={_(msg`David is the Employee, Lucas is the Manager`)}
value={context}
onChange={(e) => setContext(e.target.value)}
rows={2}
className="resize-none"
/>
<p className="text-xs text-muted-foreground">
<Trans>Help the AI assign fields to the right recipients.</Trans>
</p>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Skip</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Detect</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'PROCESSING' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detecting fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center py-8">
<AnimatedDocumentScanner />
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
{progress && (
<p className="mt-2 text-xs text-muted-foreground/60">
<Plural
value={progress.fieldsDetected}
one={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # field found
</Trans>
}
other={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # fields found
</Trans>
}
/>
</p>
)}
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
<Trans>This can take a minute or two depending on the size of your document.</Trans>
</p>
<div className="mt-4 flex gap-1">
{PROCESSING_MESSAGES.map((_, index) => (
<div
key={index}
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
}`}
/>
))}
</div>
</div>
</>
)}
{state === 'REVIEW' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detected fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto">
{detectedFields.length === 0 ? (
<div className="flex flex-col items-center py-8">
<FormInputIcon className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-center text-sm text-muted-foreground">
<Trans>No fields were detected in your document.</Trans>
</p>
<p className="mt-1 text-center text-xs text-muted-foreground/70">
<Trans>You can add fields manually in the editor.</Trans>
</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
<Plural
value={detectedFields.length}
one="We found # field in your document."
other="We found # fields in your document."
/>
</p>
<ul className="mt-4 divide-y rounded-lg border">
{fieldCountsByType.map(([type, count]) => (
<li key={type} className="flex items-center justify-between px-4 py-3">
<span className="text-sm">{_(FIELD_TYPE_LABELS[type]) || type}</span>
<span className="text-sm font-medium text-muted-foreground">{count}</span>
</li>
))}
</ul>
</>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Cancel</Trans>
</Button>
{detectedFields.length > 0 && (
<Button type="button" onClick={onAddFields}>
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Add fields</Trans>
</Button>
)}
</DialogFooter>
</>
)}
{state === 'ERROR' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detection failed</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>Something went wrong while detecting fields.</Trans>
</p>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'RATE_LIMITED' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Too many requests</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>
You've made too many detection requests. Please wait a minute before trying again.
</Trans>
</p>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -1,372 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
AiApiError,
type DetectRecipientsProgressEvent,
detectRecipients,
} from '../../../server/api/ai/detect-recipients.client';
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
type AiRecipientDetectionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: (recipients: TDetectedRecipientSchema[]) => void;
envelopeId: string;
teamId: number;
};
const PROCESSING_MESSAGES = [
msg`Reading your document`,
msg`Analyzing pages`,
msg`Looking for signature fields`,
msg`Identifying recipients`,
msg`Extracting contact details`,
msg`Almost done`,
] as const;
export const AiRecipientDetectionDialog = ({
open,
onOpenChange,
onComplete,
envelopeId,
teamId,
}: AiRecipientDetectionDialogProps) => {
const { _ } = useLingui();
const [state, setState] = useState<DialogState>('PROMPT');
const [messageIndex, setMessageIndex] = useState(0);
const [detectedRecipients, setDetectedRecipients] = useState<TDetectedRecipientSchema[]>([]);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<DetectRecipientsProgressEvent | null>(null);
const onDetectClick = useCallback(async () => {
setState('PROCESSING');
setMessageIndex(0);
setError(null);
setProgress(null);
try {
await detectRecipients({
request: {
envelopeId,
teamId,
},
onProgress: (progressEvent) => {
setProgress(progressEvent);
},
onComplete: (event) => {
setDetectedRecipients(event.recipients);
setState('REVIEW');
},
onError: (err) => {
console.error('Detection failed:', err);
if (err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err.message);
setState('ERROR');
},
});
} catch (err) {
console.error('Detection failed:', err);
if (err instanceof AiApiError && err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err instanceof Error ? err.message : 'Failed to detect recipients');
setState('ERROR');
}
}, [envelopeId, teamId]);
const handleRemoveRecipient = (index: number) => {
setDetectedRecipients((prev) => prev.filter((_, i) => i !== index));
};
const onAddRecipients = () => {
onComplete(detectedRecipients);
onOpenChange(false);
setState('PROMPT');
setDetectedRecipients([]);
};
const onClose = () => {
onOpenChange(false);
setState('PROMPT');
setDetectedRecipients([]);
setError(null);
setProgress(null);
};
useEffect(() => {
if (state !== 'PROCESSING') {
return;
}
const interval = setInterval(() => {
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
}, 4000);
return () => clearInterval(interval);
}, [state]);
return (
<Dialog open={open}>
<DialogContent className="sm:max-w-lg" hideClose={true}>
{state === 'PROMPT' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detect recipients</Trans>
</DialogTitle>
</DialogHeader>
<div>
<p className="text-sm text-muted-foreground">
<Trans>
We'll scan your document to find signature fields and identify who needs to sign.
Detected recipients will be suggested for you to review.
</Trans>
</p>
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
<AlertDescription className="mt-0">
<Trans>
Your document is processed securely using AI services that don't retain your
data.
</Trans>
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Skip</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Detect</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'PROCESSING' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detecting recipients</Trans>
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center py-8">
<AnimatedDocumentScanner />
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
{progress && (
<p className="mt-2 text-xs text-muted-foreground/60">
<Plural
value={progress.recipientsDetected}
one={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # recipient found
</Trans>
}
other={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # recipients found
</Trans>
}
/>
</p>
)}
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
<Trans>This can take a minute or two depending on the size of your document.</Trans>
</p>
<div className="mt-4 flex gap-1">
{PROCESSING_MESSAGES.map((_, index) => (
<div
key={index}
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
}`}
/>
))}
</div>
</div>
</>
)}
{state === 'REVIEW' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detected recipients</Trans>
</DialogTitle>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto">
{detectedRecipients.length === 0 ? (
<div className="flex flex-col items-center py-8">
<UserIcon className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-center text-sm text-muted-foreground">
<Trans>No recipients were detected in your document.</Trans>
</p>
<p className="mt-1 text-center text-xs text-muted-foreground/70">
<Trans>You can add recipients manually in the editor.</Trans>
</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
<Plural
value={detectedRecipients.length}
one="We found # recipient in your document."
other="We found # recipients in your document."
/>
</p>
<ul className="mt-4 divide-y rounded-lg border">
{detectedRecipients.map((recipient, index) => (
<li key={index} className="flex items-center justify-between px-4 py-3">
<AvatarWithText
avatarFallback={
recipient.name
? recipient.name.slice(0, 1).toUpperCase()
: recipient.email
? recipient.email.slice(0, 1).toUpperCase()
: '?'
}
primaryText={
<p className="text-sm font-medium text-foreground">
{recipient.name || _(msg`Unknown name`)}
</p>
}
secondaryText={
<div className="text-xs text-muted-foreground">
<p className="italic text-muted-foreground/70">
{recipient.email || _(msg`No email detected`)}
</p>
<p>{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
}
/>
<button
type="button"
className="h-8 w-8 p-0 text-muted-foreground/80 hover:text-destructive focus-visible:border-destructive focus-visible:ring-destructive"
onClick={() => handleRemoveRecipient(index)}
>
<span className="sr-only">
<Trans>Remove recipient</Trans>
</span>
<XIcon className="h-4 w-4" aria-hidden="true" />
</button>
</li>
))}
</ul>
</>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Cancel</Trans>
</Button>
{detectedRecipients.length > 0 && (
<Button type="button" onClick={onAddRecipients}>
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Add recipients</Trans>
</Button>
)}
</DialogFooter>
</>
)}
{state === 'ERROR' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detection failed</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>Something went wrong while detecting recipients.</Trans>
</p>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'RATE_LIMITED' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Too many requests</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>
You've made too many detection requests. Please wait a minute before trying again.
</Trans>
</p>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -7,7 +7,9 @@ import {
DocumentDistributionMethod,
DocumentStatus,
EnvelopeType,
type Field,
FieldType,
type Recipient,
RecipientRole,
} from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
@@ -17,9 +19,8 @@ import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import type { TEnvelope } from '@documenso/lib/types/envelope';
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';
@@ -51,13 +52,16 @@ import {
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopeDistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
recipients: Recipient[];
fields: Pick<Field, 'type' | 'recipientId'>[];
};
onDistribute?: () => Promise<void>;
documentRootPath: string;
trigger?: React.ReactNode;
@@ -82,20 +86,20 @@ export const ZEnvelopeDistributeFormSchema = z.object({
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
export const EnvelopeDistributeDialog = ({
envelope,
trigger,
documentRootPath,
onDistribute,
}: EnvelopeDistributeDialogProps) => {
const organisation = useCurrentOrganisation();
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
const recipients = envelope.recipients;
const { toast } = useToast();
const { t } = useLingui();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation();
@@ -130,43 +134,18 @@ export const EnvelopeDistributeDialog = ({
const distributionMethod = watch('meta.distributionMethod');
const recipientsWithIndex = useMemo(
() =>
envelope.recipients.map((recipient, index) => ({
...recipient,
index,
})),
[envelope.recipients],
);
const recipientsMissingSignatureFields = useMemo(
() =>
recipientsWithIndex.filter(
envelope.recipients.filter(
(recipient) =>
recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
[recipientsWithIndex, envelope.fields],
[envelope.recipients, envelope.fields],
);
/**
* List of recipients who must have an email due to having auth enabled.
*/
const recipientsMissingRequiredEmail = useMemo(() => {
return recipientsWithIndex.filter((recipient) => {
const auth = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
return (
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email
);
});
}, [recipientsWithIndex, envelope.authOptions]);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -176,12 +155,8 @@ export const EnvelopeDistributeDialog = ({
return 'MISSING_RECIPIENTS';
}
if (recipientsMissingRequiredEmail.length > 0) {
return 'MISSING_REQUIRED_EMAIL';
}
return null;
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try {
@@ -214,29 +189,6 @@ export const EnvelopeDistributeDialog = ({
}
};
const handleSync = async () => {
if (isSyncing) {
return;
}
setIsSyncing(true);
try {
await syncEnvelope();
} catch (err) {
console.error(err);
}
setIsSyncing(false);
};
useEffect(() => {
// Resync the whole envelope if the envelope is mid saving.
if (isOpen && (isAutosaving || autosaveError)) {
void handleSync();
}
}, [isOpen]);
if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) {
return null;
}
@@ -256,7 +208,7 @@ export const EnvelopeDistributeDialog = ({
</DialogDescription>
</DialogHeader>
{!invalidEnvelopeCode || isSyncing ? (
{!invalidEnvelopeCode ? (
<Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}>
@@ -284,16 +236,7 @@ export const EnvelopeDistributeDialog = ({
})}
>
<AnimatePresence initial={false} mode="wait">
{isSyncing ? (
<motion.div
key={'Flushing'}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
>
<SpinnerBox spinnerProps={{ size: 'sm' }} className="h-72" />
</motion.div>
) : distributionMethod === DocumentDistributionMethod.EMAIL ? (
{distributionMethod === DocumentDistributionMethod.EMAIL && (
<motion.div
key={'Emails'}
initial={{ opacity: 0, y: 5 }}
@@ -353,10 +296,8 @@ export const EnvelopeDistributeDialog = ({
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
<Trans>Reply To Email</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
@@ -374,10 +315,8 @@ export const EnvelopeDistributeDialog = ({
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Subject{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
<Trans>Subject</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
@@ -394,15 +333,13 @@ export const EnvelopeDistributeDialog = ({
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
<Trans>Message</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="p-4 text-muted-foreground">
<TooltipContent className="text-muted-foreground p-4">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
@@ -410,7 +347,7 @@ export const EnvelopeDistributeDialog = ({
<FormControl>
<Textarea
className="mt-2 h-16 resize-none bg-background"
className="bg-background mt-2 h-16 resize-none"
{...field}
maxLength={5000}
/>
@@ -422,7 +359,9 @@ export const EnvelopeDistributeDialog = ({
</fieldset>
</Form>
</motion.div>
) : distributionMethod === DocumentDistributionMethod.NONE ? (
)}
{distributionMethod === DocumentDistributionMethod.NONE && (
<motion.div
key={'Links'}
initial={{ opacity: 0, y: 5 }}
@@ -430,7 +369,7 @@ export const EnvelopeDistributeDialog = ({
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="min-h-60 rounded-lg border"
>
<div className="py-24 text-center text-sm text-muted-foreground">
<div className="text-muted-foreground py-24 text-center text-sm">
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
@@ -443,7 +382,7 @@ export const EnvelopeDistributeDialog = ({
</p>
</div>
</motion.div>
) : null}
)}
</AnimatePresence>
</div>
@@ -454,7 +393,7 @@ export const EnvelopeDistributeDialog = ({
</Button>
</DialogClose>
<Button loading={isSubmitting} disabled={isSyncing} type="submit">
<Button loading={isSubmitting} type="submit">
{distributionMethod === DocumentDistributionMethod.EMAIL ? (
<Trans>Send</Trans>
) : (
@@ -480,22 +419,7 @@ export const EnvelopeDistributeDialog = ({
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
</li>
))}
</ul>
</AlertDescription>
))
.with('MISSING_REQUIRED_EMAIL', () => (
<AlertDescription>
<Trans>The following recipients require an email address:</Trans>
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingRequiredEmail.map((recipient) => (
<li key={recipient.id}>
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
</li>
<li key={recipient.id}>{recipient.email}</li>
))}
</ul>
</AlertDescription>
@@ -117,7 +117,7 @@ export const EnvelopeItemDeleteDialog = ({
<DialogDescription className="mt-4">
<Trans>
You cannot delete this item because the document has been sent to recipients.
You cannot delete this item because the document has been sent to recipients
</Trans>
</DialogDescription>
</DialogHeader>
@@ -367,7 +367,7 @@ const BillingPlanForm = ({
<div className="w-full text-left">
<div className="flex items-center justify-between">
<p className="text-medium">
<Trans context="Plan price">Free</Trans>
<Trans>Free</Trans>
</p>
<Badge size="small" variant="neutral" className="ml-1.5">
@@ -72,7 +72,6 @@ export const OrganisationEmailCreateDialog = ({
const { mutateAsync: createOrganisationEmail, isPending } =
trpc.enterprise.organisation.email.create.useMutation();
// Reset state when dialog closes
useEffect(() => {
if (!open) {
form.reset();
@@ -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>
@@ -0,0 +1,251 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import type { RecipientForCreation } from '~/utils/detect-document-recipients';
import { SuggestedRecipientsForm } from './suggested-recipients-form';
type RecipientDetectionStep =
| 'PROMPT_DETECT_RECIPIENTS'
| 'DETECTING_RECIPIENTS'
| 'REVIEW_RECIPIENTS'
| 'DETECTING_FIELDS';
export type RecipientDetectionPromptDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onAccept: () => Promise<void> | void;
onSkip: () => void;
recipients: RecipientForCreation[] | null;
onRecipientsSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
onAutoAddFields?: (recipients: RecipientForCreation[]) => Promise<void> | void;
isProcessingRecipients?: boolean;
};
export const RecipientDetectionPromptDialog = ({
open,
onOpenChange,
onAccept,
onSkip,
recipients,
onRecipientsSubmit,
onAutoAddFields,
isProcessingRecipients = false,
}: RecipientDetectionPromptDialogProps) => {
const [currentStep, setCurrentStep] = useState<RecipientDetectionStep>(
'PROMPT_DETECT_RECIPIENTS',
);
const [currentRecipients, setCurrentRecipients] = useState<RecipientForCreation[] | null>(
recipients,
);
useEffect(() => {
if (!open) {
setCurrentStep('PROMPT_DETECT_RECIPIENTS');
}
}, [open]);
useEffect(() => {
setCurrentRecipients(recipients);
}, [recipients]);
useEffect(() => {
if (recipients && currentStep === 'DETECTING_RECIPIENTS') {
setCurrentStep('REVIEW_RECIPIENTS');
}
}, [recipients, currentStep]);
const handleStartDetection = (e: React.MouseEvent) => {
e.preventDefault();
setCurrentStep('DETECTING_RECIPIENTS');
Promise.resolve(onAccept()).catch(() => {
setCurrentStep('PROMPT_DETECT_RECIPIENTS');
});
};
const handleSkip = () => {
onSkip();
};
const handleAutoAddFields = async (recipients: RecipientForCreation[]) => {
if (!onAutoAddFields) {
return;
}
// Save the current state of recipients so if we fail and come back,
// the form is restored with the user's changes.
setCurrentRecipients(recipients);
setCurrentStep('DETECTING_FIELDS');
try {
await onAutoAddFields(recipients);
} catch {
setCurrentStep('REVIEW_RECIPIENTS');
}
};
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
// Prevent closing during processing
if (
!newOpen &&
(currentStep === 'DETECTING_RECIPIENTS' ||
currentStep === 'DETECTING_FIELDS' ||
isProcessingRecipients)
) {
return;
}
onOpenChange(newOpen);
}}
>
<DialogContent
className={
currentStep === 'REVIEW_RECIPIENTS' ? 'max-h-[90vh] max-w-4xl overflow-y-auto' : ''
}
>
<fieldset
disabled={currentStep === 'DETECTING_RECIPIENTS' || currentStep === 'DETECTING_FIELDS'}
>
<AnimateGenericFadeInOut motionKey={currentStep} className="grid gap-4">
{match(currentStep)
.with('PROMPT_DETECT_RECIPIENTS', () => (
<>
<DialogHeader>
<DialogTitle>
<Trans>Auto-detect recipients?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Would you like to automatically detect recipients in your document? This can
save you time in setting up your document.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={handleSkip}>
<Trans>Skip for now</Trans>
</Button>
<Button onClick={handleStartDetection}>
<Trans>Detect recipients</Trans>
</Button>
</DialogFooter>
</>
))
.with('DETECTING_RECIPIENTS', () => (
<div className="flex flex-col items-center justify-center py-4">
<div className="relative mb-4 flex items-center justify-center">
<div
className="border-muted-foreground/20 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left flex-col gap-y-1 overflow-hidden rounded-lg border px-2 py-4 backdrop-blur-sm"
style={{ transform: 'translateZ(0px)' }}
>
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-5/6 rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-4/5 rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-3/4 rounded-[2px]"></div>
</div>
<div
className="bg-documenso/80 animate-scan pointer-events-none absolute left-1/2 top-0 z-20 h-0.5 w-24 -translate-x-1/2"
style={{
transform: 'translateX(-50%) translateZ(0px)',
}}
/>
</div>
<DialogHeader>
<DialogTitle className="text-center">
<Trans>Analyzing your document</Trans>
</DialogTitle>
<DialogDescription className="text-center">
<Trans>
Scanning your document to detect recipient names, emails, and signing order.
</Trans>
</DialogDescription>
</DialogHeader>
</div>
))
.with('DETECTING_FIELDS', () => (
<div className="flex flex-col items-center justify-center py-4">
<div className="relative mb-4 flex items-center justify-center">
<div
className="border-muted-foreground/20 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 origin-top-left flex-col gap-y-1 overflow-hidden rounded-lg border px-2 py-4 backdrop-blur-sm"
style={{ transform: 'translateZ(0px)' }}
>
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-5/6 rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-4/5 rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-full rounded-[2px]"></div>
<div className="bg-muted-foreground/20 h-2 w-3/4 rounded-[2px]"></div>
</div>
<div
className="bg-documenso/80 animate-scan pointer-events-none absolute left-1/2 top-0 z-20 h-0.5 w-24 -translate-x-1/2"
style={{
transform: 'translateX(-50%) translateZ(0px)',
}}
/>
</div>
<DialogHeader>
<DialogTitle className="text-center">
<Trans>Detecting fields</Trans>
</DialogTitle>
<DialogDescription className="text-center">
<Trans>
Scanning your document to intelligently place fields for your recipients.
</Trans>
</DialogDescription>
</DialogHeader>
</div>
))
.with('REVIEW_RECIPIENTS', () => (
<>
<DialogHeader>
<DialogTitle>
<Trans>Review detected recipients</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Confirm, edit, or add recipients before continuing. You can adjust any
information below before importing it into your document.
</Trans>
</DialogDescription>
</DialogHeader>
<SuggestedRecipientsForm
recipients={currentRecipients}
onCancel={handleSkip}
onSubmit={onRecipientsSubmit}
onAutoAddFields={onAutoAddFields ? handleAutoAddFields : undefined}
isProcessing={isProcessingRecipients}
/>
</>
))
.exhaustive()}
</AnimateGenericFadeInOut>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -24,10 +24,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
const ZSignFieldEmailFormSchema = z.object({
email: z
.string()
.email()
.min(1, { message: msg`Email is required`.id }),
email: z.string().min(1, { message: msg`Email is required`.id }),
});
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
@@ -17,7 +17,6 @@ import { DocumentSigningDisclosure } from '../general/document-signing/document-
export type SignFieldSignatureDialogProps = {
initialSignature?: string;
fullName?: string;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
@@ -29,7 +28,6 @@ export const SignFieldSignatureDialog = createCallable<
>(
({
call,
fullName,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -48,7 +46,6 @@ export const SignFieldSignatureDialog = createCallable<
</DialogHeader>
<SignaturePad
fullName={fullName}
value={localSignature ?? ''}
onChange={({ value }) => setLocalSignature(value)}
typedSignatureEnabled={typedSignatureEnabled}
@@ -0,0 +1,408 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { PlusIcon, TrashIcon } from 'lucide-react';
import { type FieldError, useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { nanoid } from '@documenso/lib/universal/id';
import { trpc } from '@documenso/trpc/react';
import { RecipientAutoCompleteInput } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import type { RecipientAutoCompleteOption } from '@documenso/ui/components/recipient/recipient-autocomplete-input';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import type { RecipientForCreation } from '~/utils/detect-document-recipients';
const ZSuggestedRecipientSchema = z.object({
formId: z.string().min(1),
name: z
.string()
.min(1, { message: msg`Name is required`.id })
.max(255),
email: z
.string()
.min(1, { message: msg`Email is required`.id })
.email({ message: msg`Invalid email`.id })
.max(254),
role: z.nativeEnum(RecipientRole),
});
const ZSuggestedRecipientsFormSchema = z.object({
recipients: z
.array(ZSuggestedRecipientSchema)
.min(1, { message: msg`Please add at least one recipient`.id }),
});
type TSuggestedRecipientsFormSchema = z.infer<typeof ZSuggestedRecipientsFormSchema>;
export type SuggestedRecipientsFormProps = {
recipients: RecipientForCreation[] | null;
onCancel: () => void;
onSubmit: (recipients: RecipientForCreation[]) => Promise<void> | void;
onAutoAddFields?: (recipients: RecipientForCreation[]) => Promise<void> | void;
isProcessing?: boolean;
};
export const SuggestedRecipientsForm = ({
recipients,
onCancel,
onSubmit,
onAutoAddFields,
isProcessing = false,
}: SuggestedRecipientsFormProps) => {
const { t } = useLingui();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const { data: recipientSuggestionsData, isLoading } = trpc.recipient.suggestions.find.useQuery(
{
query: debouncedRecipientSearchQuery,
},
{
enabled: debouncedRecipientSearchQuery.length > 1,
},
);
const recipientSuggestions = recipientSuggestionsData?.results || [];
const defaultRecipients = useMemo(() => {
if (recipients && recipients.length > 0) {
const sorted = [...recipients].sort((a, b) => {
const orderA = a.signingOrder ?? 0;
const orderB = b.signingOrder ?? 0;
return orderA - orderB;
});
return sorted.map((recipient) => ({
formId: nanoid(),
name: recipient.name,
email: recipient.email,
role: recipient.role,
}));
}
return [
{
formId: nanoid(),
name: '',
email: '',
role: RecipientRole.SIGNER,
},
];
}, [recipients]);
const form = useForm<TSuggestedRecipientsFormSchema>({
resolver: zodResolver(ZSuggestedRecipientsFormSchema),
defaultValues: {
recipients: defaultRecipients,
},
});
const {
formState: { isSubmitting },
} = form;
useEffect(() => {
form.reset({
recipients: defaultRecipients,
});
}, [defaultRecipients, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'recipients',
});
const handleRecipientAutoCompleteSelect = (
index: number,
suggestion: RecipientAutoCompleteOption,
) => {
form.setValue(`recipients.${index}.email`, suggestion.email);
form.setValue(`recipients.${index}.name`, suggestion.name ?? suggestion.email);
};
const handleAddSigner = () => {
append({
formId: nanoid(),
name: '',
email: '',
role: RecipientRole.SIGNER,
});
};
const handleRemoveSigner = (index: number) => {
remove(index);
};
const handleSubmit = form.handleSubmit(async (values) => {
const normalizedRecipients: RecipientForCreation[] = values.recipients.map(
(recipient, index) => ({
name: recipient.name.trim(),
email: recipient.email.trim(),
role: recipient.role,
signingOrder: index + 1,
}),
);
try {
await onSubmit(normalizedRecipients);
} catch (error) {
console.error('Failed to submit recipients:', error);
}
});
const handleAutoAddFields = form.handleSubmit(async (values) => {
if (!onAutoAddFields) {
return;
}
const normalizedRecipients: RecipientForCreation[] = values.recipients.map(
(recipient, index) => ({
name: recipient.name.trim(),
email: recipient.email.trim(),
role: recipient.role,
signingOrder: index + 1,
}),
);
try {
await onAutoAddFields(normalizedRecipients);
} catch (error) {
console.error('Failed to auto-add fields:', error);
}
});
const getRecipientsRootError = (
error: typeof form.formState.errors.recipients,
): FieldError | undefined => {
if (typeof error !== 'object' || !error || !('root' in error)) {
return undefined;
}
const candidate = (error as { root?: FieldError }).root;
return typeof candidate === 'object' ? candidate : undefined;
};
const recipientsRootError = getRecipientsRootError(form.formState.errors.recipients);
return (
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
{fields.map((field, index) => (
<div
key={field.id}
className="flex flex-col gap-4 md:flex-row md:items-center md:gap-x-2"
>
<FormField
control={form.control}
name={`recipients.${index}.email`}
render={({ field: emailField }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.email,
})}
>
{index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="email"
placeholder={t`Email`}
value={emailField.value}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
emailField.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
disabled={isSubmitting}
maxLength={254}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`recipients.${index}.name`}
render={({ field: nameField }) => (
<FormItem
className={cn('w-full', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.name,
})}
>
{index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
value={nameField.value}
options={recipientSuggestions}
onSelect={(suggestion) =>
handleRecipientAutoCompleteSelect(index, suggestion)
}
onSearchQueryChange={(query) => {
nameField.onChange(query);
setRecipientSearchQuery(query);
}}
loading={isLoading}
disabled={isSubmitting}
maxLength={255}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`recipients.${index}.role`}
render={({ field: roleField }) => (
<FormItem
className={cn('mt-2 w-full md:mt-auto md:w-fit', {
'mb-6':
form.formState.errors.recipients?.[index] &&
!form.formState.errors.recipients[index]?.role,
})}
>
{index === 0 && (
<FormLabel>
<Trans>Role</Trans>
</FormLabel>
)}
<FormControl>
<RecipientRoleSelect
value={roleField.value}
onValueChange={roleField.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
className={cn('mt-2 w-full px-2 md:mt-auto md:w-auto', {
'mb-6': form.formState.errors.recipients?.[index],
})}
onClick={() => handleRemoveSigner(index)}
disabled={isSubmitting || fields.length === 1}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<FormErrorMessage className="mt-2" error={recipientsRootError} />
<div className="flex">
<Button
type="button"
variant="outline"
onClick={handleAddSigner}
className="w-full md:w-auto"
disabled={isSubmitting}
>
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add signer</Trans>
</Button>
</div>
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-between sm:gap-3">
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={isSubmitting || isProcessing}
>
<Trans>Cancel</Trans>
</Button>
<div className="flex flex-col gap-2 sm:flex-row sm:gap-3">
<Button type="submit" disabled={isSubmitting || isProcessing}>
<Trans>Use recipients</Trans>
</Button>
{onAutoAddFields && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
type="button"
onClick={handleAutoAddFields}
disabled={
isSubmitting ||
isProcessing ||
fields.length === 0 ||
!form.formState.isValid
}
>
{isProcessing ? (
<Trans>Processing...</Trans>
) : (
<Trans>Auto add fields</Trans>
)}
</Button>
</div>
</TooltipTrigger>
{(fields.length === 0 || !form.formState.isValid) && (
<TooltipContent>
<Trans>Please add at least one valid recipient to auto-detect fields</Trans>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</div>
</DialogFooter>
</form>
</Form>
);
};
@@ -8,7 +8,6 @@ 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';
@@ -31,13 +30,6 @@ 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';
@@ -61,34 +53,26 @@ 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 (data: z.infer<typeof ZDeleteTeamFormSchema>) => {
const onFormSubmit = async () => {
try {
const transferTeamId = data.transferTeamId ? parseInt(data.transferTeamId, 10) : undefined;
await deleteTeam({ teamId, transferTeamId: transferTeamId || undefined });
await deleteTeam({ teamId });
await refreshSession();
@@ -184,43 +168,6 @@ 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>
@@ -103,8 +103,8 @@ export const TemplateBulkSendDialog = ({
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
title: 'Error',
description: 'Failed to upload CSV. Please check the file format and try again.',
variant: 'destructive',
});
}
@@ -0,0 +1,136 @@
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>
);
};
@@ -21,7 +21,6 @@ import {
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -66,7 +65,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
id: z.number(),
email: ZRecipientEmailSchema,
email: z.string().email(),
name: z.string(),
signingOrder: z.number().optional(),
}),
@@ -101,29 +100,12 @@ export function TemplateUseDialog({
const [open, setOpen] = useState(false);
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
envelopeId,
},
{
placeholderData: (previousData) => previousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
enabled: open,
},
);
const envelopeItems = response?.data ?? [];
const generateDefaultFormValues = () => {
return {
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
distributeDocument: false,
useCustomDocument: false,
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
envelopeItemId: item.id,
})),
customDocumentData: [],
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
@@ -142,12 +124,7 @@ export function TemplateUseDialog({
signingOrder: recipient.signingOrder ?? undefined,
};
}),
};
};
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: generateDefaultFormValues(),
},
});
const { replace, fields: localCustomDocumentData } = useFieldArray({
@@ -155,6 +132,19 @@ export function TemplateUseDialog({
name: 'customDocumentData',
});
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
envelopeId,
},
{
placeholderData: (previousData) => previousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const envelopeItems = response?.data ?? [];
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
@@ -224,8 +214,8 @@ export function TemplateUseDialog({
});
useEffect(() => {
if (open) {
form.reset(generateDefaultFormValues());
if (!open) {
form.reset();
}
}, [open, form]);
@@ -332,7 +322,7 @@ export function TemplateUseDialog({
<Input
{...field}
aria-label="Name"
placeholder={recipients[index].name || _(msg`Recipient ${index + 1}`)}
placeholder={recipients[index].name || _(msg`Name`)}
/>
</FormControl>
<FormMessage />
@@ -359,7 +349,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label
className="ml-2 flex items-center text-sm text-muted-foreground"
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="distributeDocument"
>
<Trans>Send document</Trans>
@@ -368,7 +358,7 @@ export function TemplateUseDialog({
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
The document will be immediately sent to recipients if this
@@ -388,7 +378,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="ml-2 flex items-center text-sm text-muted-foreground"
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
@@ -396,7 +386,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
Create the document as pending and ready to sign.
@@ -442,7 +432,7 @@ export function TemplateUseDialog({
}}
/>
<label
className="ml-2 flex items-center text-sm text-muted-foreground"
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="useCustomDocument"
>
<Trans>Upload custom document</Trans>
@@ -450,7 +440,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
Upload a custom document to use instead of the template's default
@@ -480,19 +470,19 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
>
<div className="flex-shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<FileTextIcon className="text-primary h-5 w-5" />
</div>
</div>
<div className="min-w-0 flex-1">
<h4 className="truncate text-sm font-medium text-foreground">
<h4 className="text-foreground truncate text-sm font-medium">
{item.title}
</h4>
<p className="mt-0.5 text-xs text-muted-foreground">
<p className="text-muted-foreground mt-0.5 text-xs">
{field.value ? (
<div>
<Trans>
@@ -219,8 +219,9 @@ 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>
@@ -29,7 +29,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
// Define a type for signer items
type SignerItem = TConfigureEmbedFormSchema['signers'][number];
export interface ConfigureDocumentRecipientsProps {
@@ -97,18 +96,15 @@ export const ConfigureDocumentRecipients = ({
const currentSigners = getValues('signers') || [...signers];
const signer = currentSigners[index];
// Remove signer from current position and insert at new position
const remainingSigners = currentSigners.filter((_: unknown, idx: number) => idx !== index);
const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1);
remainingSigners.splice(newPosition, 0, signer);
// Update signing order for each item
const updatedSigners = remainingSigners.map((s: SignerItem, idx: number) => ({
...s,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? idx + 1 : undefined,
}));
// Update the form
replace(updatedSigners);
},
[signers, replace, getValues],
@@ -118,17 +114,14 @@ export const ConfigureDocumentRecipients = ({
(result: DropResult) => {
if (!result.destination) return;
// Use the move function from useFieldArray which preserves input values
move(result.source.index, result.destination.index);
// Update signing orders after move
const currentSigners = getValues('signers');
const updatedSigners = currentSigners.map((signer: SignerItem, index: number) => ({
...signer,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? index + 1 : undefined,
}));
// Update the form with new ordering
replace(updatedSigners);
},
[move, replace, getValues],
@@ -5,7 +5,6 @@ import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaLanguageSchema,
} from '@documenso/lib/types/document-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
// Define the schema for configuration
@@ -56,7 +55,7 @@ export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.exten
nativeId: z.number().optional(),
formId: z.string(),
name: z.string(),
email: ZRecipientEmailSchema,
email: z.union([z.string().length(0), z.string().email('Invalid email address')]),
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client';
@@ -230,8 +229,8 @@ export const ConfigureFieldsView = ({
setFieldClipboard(lastActiveField);
toast({
title: _(msg`Copied field`),
description: _(msg`Copied field to clipboard`),
title: 'Copied field',
description: 'Copied field to clipboard',
});
}
},
@@ -438,7 +438,6 @@ export const EmbedDirectTemplateClientPage = ({
className="mt-2"
disabled={isThrottled || isSubmitting}
disableAnimation
fullName={fullName}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
@@ -455,7 +455,6 @@ export const EmbedSignDocumentV1ClientPage = ({
className="mt-2"
disabled={isThrottled || isSubmitting}
disableAnimation
fullName={fullName}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
@@ -150,8 +150,8 @@ export const MultiSignDocumentSigningView = ({
onDocumentError?.();
toast({
title: _(msg`Error`),
description: _(msg`Failed to complete the document. Please try again.`),
title: 'Error',
description: 'Failed to complete the document. Please try again.',
variant: 'destructive',
});
} finally {
@@ -319,7 +319,6 @@ export const MultiSignDocumentSigningView = ({
className="mt-2"
disabled={isSubmitting}
disableAnimation
fullName={fullName}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={
@@ -58,7 +58,6 @@ export type TDocumentPreferencesFormSchema = {
includeSigningCertificate: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
aiFeaturesEnabled: boolean | null;
};
type SettingsSubset = Pick<
@@ -73,13 +72,11 @@ type SettingsSubset = Pick<
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'aiFeaturesEnabled'
>;
export type DocumentPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
isAiFeaturesConfigured?: boolean;
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
@@ -87,7 +84,6 @@ export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
canInherit,
isAiFeaturesConfigured = false,
}: DocumentPreferencesFormProps) => {
const { t } = useLingui();
const { user, organisations } = useSession();
@@ -109,7 +105,6 @@ export const DocumentPreferencesForm = ({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
aiFeaturesEnabled: z.boolean().nullable(),
});
const form = useForm<TDocumentPreferencesFormSchema>({
@@ -125,7 +120,6 @@ export const DocumentPreferencesForm = ({
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
aiFeaturesEnabled: settings.aiFeaturesEnabled,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
@@ -318,7 +312,7 @@ export const DocumentPreferencesForm = ({
}))}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
className="bg-background w-full"
enableSearch={false}
emptySelectionPlaceholder={
canInherit ? t`Inherit from organisation` : t`Select signature types`
@@ -384,7 +378,7 @@ export const DocumentPreferencesForm = ({
</FormControl>
<div className="pt-2">
<div className="text-xs font-medium text-muted-foreground">
<div className="text-muted-foreground text-xs font-medium">
<Trans>Preview</Trans>
</div>
@@ -515,59 +509,6 @@ export const DocumentPreferencesForm = ({
)}
/>
{isAiFeaturesConfigured && (
<FormField
control={form.control}
name="aiFeaturesEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>AI Features</Trans>
</FormLabel>
<FormControl>
<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>Enabled</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>Disabled</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Enable AI-powered features such as automatic recipient detection. When
enabled, document content will be sent to AI providers. We only use providers
that do not retain data for training and prefer European regions where
available.
</Trans>
</FormDescription>
</FormItem>
)}
/>
)}
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
+1 -2
View File
@@ -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="mt-2 bg-muted" value={user.email} disabled />
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
</div>
<FormField
@@ -124,7 +124,6 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
<FormControl>
<SignaturePadDialog
disabled={isSubmitting}
fullName={user.name ?? ''}
value={value}
onChange={(v) => onChange(v ?? '')}
/>
+3 -3
View File
@@ -201,7 +201,7 @@ export const SignInForm = ({
.otherwise(() => handleFallbackErrorMessages(error.code));
toast({
title: _(msg`Something went wrong`),
title: 'Something went wrong',
description: _(errorMessage),
duration: 10000,
variant: 'destructive',
@@ -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));
+1 -1
View File
@@ -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.`);
@@ -1,78 +0,0 @@
import { useEffect, useState } from 'react';
import { SearchIcon } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
export type AnimatedDocumentScannerProps = {
className?: string;
interval?: number;
};
export const AnimatedDocumentScanner = ({
className,
interval = 2500,
}: AnimatedDocumentScannerProps) => {
const [magPosition, setMagPosition] = useState({ x: 0, y: 0, page: 0 });
useEffect(() => {
const moveInterval = setInterval(() => {
setMagPosition({
x: Math.random() * 60 - 30,
y: Math.random() * 50 - 25,
page: Math.random() > 0.5 ? 1 : 0,
});
}, interval);
return () => clearInterval(moveInterval);
}, [interval]);
return (
<div className={cn('relative mx-auto h-36 w-56', className)}>
{/* Magnifying glass */}
<div
className="pointer-events-none absolute z-50 transition-all duration-1000 ease-in-out"
style={{
left: magPosition.page === 0 ? '25%' : '75%',
top: '50%',
transform: `translate(calc(-50% + ${magPosition.x}px), calc(-50% + ${magPosition.y}px))`,
}}
>
<SearchIcon className="h-8 w-8 text-foreground" />
</div>
{/* Book container */}
<div className="relative h-full w-full animate-pulse" style={{ perspective: '800px' }}>
<div className="relative h-full w-full" style={{ transformStyle: 'preserve-3d' }}>
{/* Left page */}
<div
className="absolute left-0 top-0 h-full w-[calc(50%)] origin-right overflow-hidden rounded-l-md border border-border bg-card shadow-md"
style={{ transform: 'rotateY(15deg) skewY(-1deg)' }}
>
<div className="absolute inset-3 space-y-2">
<div className="h-1.5 w-3/4 rounded-sm bg-muted" />
<div className="h-1.5 w-full rounded-sm bg-muted" />
<div className="h-1.5 w-5/6 rounded-sm bg-muted" />
<div className="h-1.5 w-2/3 rounded-sm bg-muted" />
<div className="mt-3 h-6 w-3/4 rounded border border-dashed border-primary" />
</div>
</div>
{/* Right page */}
<div
className="absolute right-0 top-0 h-full w-[calc(50%)] origin-left overflow-hidden rounded-r-md border border-border bg-card shadow-md"
style={{ transform: 'rotateY(-15deg) skewY(1deg)' }}
>
<div className="absolute inset-3 space-y-2">
<div className="h-1.5 w-full rounded-sm bg-muted" />
<div className="h-1.5 w-4/5 rounded-sm bg-muted" />
<div className="h-1.5 w-full rounded-sm bg-muted" />
<div className="h-1.5 w-3/5 rounded-sm bg-muted" />
<div className="mt-3 h-6 w-2/3 rounded border border-dashed border-primary" />
</div>
</div>
</div>
</div>
</div>
);
};
@@ -56,13 +56,13 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
/>
<div
className="text-sm text-muted-foreground"
className="text-muted-foreground text-sm"
title={
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
}
>
<p>{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
<p>{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -417,7 +417,6 @@ export const DirectTemplateSigningForm = ({
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
fullName={fullName}
value={signature ?? ''}
onChange={(value) => setSignature(value)}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
@@ -434,7 +433,7 @@ export const DirectTemplateSigningForm = ({
<div className="mt-4 flex gap-x-4">
<Button
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
size="lg"
variant="secondary"
disabled={isSubmitting}
@@ -57,13 +57,12 @@ export type DocumentSigningCompleteDialogProps = {
name: string;
email: string;
};
recipientPayload?: {
directTemplatePayload?: {
name: string;
email: string;
};
buttonSize?: 'sm' | 'lg';
position?: 'start' | 'end' | 'center';
disableNameInput?: boolean;
};
const ZNextSignerFormSchema = z.object({
@@ -90,11 +89,10 @@ export const DocumentSigningCompleteDialog = ({
recipient,
disabled = false,
allowDictateNextSigner = false,
recipientPayload,
directTemplatePayload,
defaultNextSigner,
buttonSize = 'lg',
position,
disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => {
const { t } = useLingui();
@@ -115,11 +113,11 @@ export const DocumentSigningCompleteDialog = ({
},
});
const recipientForm = useForm<TDirectRecipientFormSchema>({
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: {
name: recipientPayload?.name ?? '',
email: recipientPayload?.email ?? '',
name: directTemplatePayload?.name ?? '',
email: directTemplatePayload?.email ?? '',
},
});
@@ -147,16 +145,16 @@ export const DocumentSigningCompleteDialog = ({
const onFormSubmit = async (data: TNextSignerFormSchema) => {
try {
let recipientOverridePayload: { name: string; email: string } | undefined;
let directRecipient: { name: string; email: string } | undefined;
if (recipientPayload && !recipientPayload.email) {
const isFormValid = await recipientForm.trigger();
if (directTemplatePayload && !directTemplatePayload.email) {
const isFormValid = await directRecipientForm.trigger();
if (!isFormValid) {
return;
}
recipientOverridePayload = recipientForm.getValues();
directRecipient = directRecipientForm.getValues();
}
// Check if 2FA is required
@@ -170,7 +168,7 @@ export const DocumentSigningCompleteDialog = ({
? { name: data.name, email: data.email }
: undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
} catch (error) {
const err = AppError.parseError(error);
@@ -224,7 +222,7 @@ export const DocumentSigningCompleteDialog = ({
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<div className="max-w-[50ch] text-muted-foreground">
<div className="text-muted-foreground max-w-[50ch]">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<span className="inline-flex flex-wrap">
@@ -252,19 +250,19 @@ export const DocumentSigningCompleteDialog = ({
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-border bg-muted/50 p-4 text-center">
<p className="text-sm font-medium text-muted-foreground">{documentTitle}</p>
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
</div>
{!showTwoFactorForm && (
<>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
{recipientPayload && !recipientPayload.email && (
<Form {...recipientForm}>
{directTemplatePayload && !directTemplatePayload.email && (
<Form {...directRecipientForm}>
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={recipientForm.control}
control={directRecipientForm.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
@@ -276,7 +274,7 @@ export const DocumentSigningCompleteDialog = ({
{...field}
className="mt-2"
placeholder={t`Enter your name`}
disabled={isNameLocked || disableNameInput}
disabled={isNameLocked}
/>
</FormControl>
@@ -286,7 +284,7 @@ export const DocumentSigningCompleteDialog = ({
/>
<FormField
control={recipientForm.control}
control={directRecipientForm.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
@@ -108,8 +108,8 @@ export const DocumentSigningForm = ({
await completeDocument({ nextSigner });
} catch (err) {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while completing the document. Please try again.`),
title: 'Error',
description: 'An error occurred while completing the document. Please try again.',
variant: 'destructive',
});
@@ -280,7 +280,6 @@ export const DocumentSigningForm = ({
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
fullName={fullName}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
@@ -106,7 +106,7 @@ export const DocumentSigningMobileWidget = () => {
<motion.div
layout="size"
layoutId="document-signing-mobile-widget-progress-bar"
className="bg-primary absolute inset-y-0 left-0"
className="bg-documenso absolute inset-y-0 left-0"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
@@ -187,74 +187,45 @@ export const DocumentSigningPageViewV1 = ({
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-y-2 sm:mt-2.5 sm:gap-y-0">
<div className="max-w-[50ch]">
<span className="truncate text-muted-foreground" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
<span className="text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to view this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to view this document
</Trans>
<Trans>has invited you to view this document</Trans>
),
)
.with(RecipientRole.SIGNER, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to sign this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to sign this document
</Trans>
<Trans>has invited you to sign this document</Trans>
),
)
.with(RecipientRole.APPROVER, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to approve this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to approve this document
</Trans>
<Trans>has invited you to approve this document</Trans>
),
)
.with(RecipientRole.ASSISTANT, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to assist this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to assist this document
</Trans>
<Trans>has invited you to assist this document</Trans>
),
)
.otherwise(() => null)}
@@ -109,7 +109,7 @@ export const DocumentSigningPageViewV2 = () => {
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
className="absolute inset-y-0 left-0 bg-documenso"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
@@ -74,8 +74,8 @@ export function DocumentSigningRejectDialog({
});
toast({
title: t`Document rejected`,
description: t`The document has been successfully rejected.`,
title: 'Document rejected',
description: 'The document has been successfully rejected.',
duration: 5000,
});
@@ -88,8 +88,8 @@ export function DocumentSigningRejectDialog({
}
} catch (err) {
toast({
title: t`Error`,
description: t`An error occurred while rejecting the document. Please try again.`,
title: 'Error',
description: 'An error occurred while rejecting the document. Please try again.',
variant: 'destructive',
duration: 5000,
});
@@ -56,11 +56,8 @@ export const DocumentSigningSignatureField = ({
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState(2);
const {
fullName,
signature: providedSignature,
setSignature: setProvidedSignature,
} = useRequiredDocumentSigningContext();
const { signature: providedSignature, setSignature: setProvidedSignature } =
useRequiredDocumentSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
@@ -239,13 +236,13 @@ export const DocumentSigningSignatureField = ({
type="Signature"
>
{isLoading && (
<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 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>
)}
{state === 'empty' && (
<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">
<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">
<Trans>Signature</Trans>
</p>
)}
@@ -262,7 +259,7 @@ export const DocumentSigningSignatureField = ({
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
<p
ref={signatureRef}
className="w-full overflow-hidden break-all text-center font-signature leading-tight text-muted-foreground duration-200"
className="font-signature text-muted-foreground w-full overflow-hidden break-all text-center leading-tight duration-200"
style={{ fontSize: `${fontSize}rem` }}
>
{signature?.typedSignature}
@@ -275,13 +272,12 @@ export const DocumentSigningSignatureField = ({
<DialogTitle>
<Trans>
Sign as {recipient.name}{' '}
<div className="h-5 text-muted-foreground">({recipient.email})</div>
<div className="text-muted-foreground h-5">({recipient.email})</div>
</Trans>
</DialogTitle>
<SignaturePad
className="mt-2"
fullName={fullName}
value={localSignature ?? ''}
onChange={({ value }) => setLocalSignature(value)}
typedSignatureEnabled={typedSignatureEnabled}
@@ -14,10 +14,9 @@ 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, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { formatDocumentsPath } 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 {
@@ -32,13 +31,9 @@ import { useCurrentTeam } from '~/providers/team';
export type DocumentUploadButtonLegacyProps = {
className?: string;
type: EnvelopeType;
};
export const DocumentUploadButtonLegacy = ({
className,
type,
}: DocumentUploadButtonLegacyProps) => {
export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLegacyProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
@@ -59,18 +54,8 @@ export const DocumentUploadButtonLegacy = ({
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`;
}
@@ -79,8 +64,11 @@ export const DocumentUploadButtonLegacy = ({
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, type]);
}, [remaining.documents, user.emailVerified, team]);
const onFileDrop = async (file: File) => {
try {
@@ -92,62 +80,44 @@ export const DocumentUploadButtonLegacy = ({
meta: {
timezone: userTimezone,
},
} satisfies TCreateDocumentPayloadSchema | TCreateTemplatePayloadSchema;
} satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
// Handle legacy document creation.
if (type === EnvelopeType.DOCUMENT) {
const { envelopeId: id } = await createDocument(formData);
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(),
});
}
// 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,
});
}
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
} 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.`);
@@ -179,18 +149,17 @@ export const DocumentUploadButtonLegacy = ({
<div>
<DocumentUploadButtonPrimitive
loading={isLoading}
disabled={disabledMessage !== undefined}
disabled={remaining.documents === 0 || !user.emailVerified}
disabledMessage={disabledMessage}
onDrop={async (files) => onFileDrop(files[0])}
onDropRejected={onFileDropRejected}
type={type}
type={EnvelopeType.DOCUMENT}
internalVersion="1"
/>
</div>
</TooltipTrigger>
{team?.id === undefined &&
type === EnvelopeType.DOCUMENT &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<TooltipContent>
@@ -174,18 +174,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Initialize snap guides layer
// snapGuideLayer.current = initializeSnapGuides(stage.current);
// Add transformer for resizing and rotating.
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
// Handle stage click to deselect.
currentStage.on('mousedown', (e) => {
removePendingField();
@@ -270,7 +264,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
let y2: number;
currentStage.on('mousedown touchstart', (e) => {
// do nothing if we mousedown on any shape
if (e.target !== currentStage) {
return;
}
@@ -296,7 +289,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
});
currentStage.on('mousemove touchmove', () => {
// do nothing if we didn't start selection
if (!selectionRectangle.visible()) {
return;
}
@@ -321,7 +313,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
});
currentStage.on('mouseup touchend', () => {
// do nothing if we didn't start selection
if (!selectionRectangle.visible()) {
return;
}
@@ -375,34 +366,25 @@ export default function EnvelopeEditorFieldsPageRenderer() {
return;
}
// If empty area clicked, remove all selections
if (e.target === stage.current) {
setSelectedFields([]);
return;
}
// Do nothing if field not clicked, or if field is not editable
if (!e.target.hasName('field-group') || e.target.draggable() === false) {
return;
}
// do we pressed shift or ctrl?
const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey;
const isSelected = transformer.nodes().indexOf(e.target) >= 0;
if (!metaPressed && !isSelected) {
// if no key pressed and the node is not selected
// select just one
setSelectedFields([e.target]);
} else if (metaPressed && isSelected) {
// if we pressed keys and node was selected
// we need to remove it from selection:
const nodes = transformer.nodes().slice(); // use slice to have new copy of array
// remove node from array
const nodes = transformer.nodes().slice();
nodes.splice(nodes.indexOf(e.target), 1);
setSelectedFields(nodes);
} else if (metaPressed && !isSelected) {
// add the node into selection
const nodes = transformer.nodes().concat([e.target]);
setSelectedFields(nodes);
}
@@ -429,12 +411,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}
});
// If it exists, rerender.
localPageFields.forEach((field) => {
renderFieldOnLayer(field);
});
// Rerender the transformer
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
@@ -1,41 +1,39 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { Link, useRevalidator, useSearchParams } from 'react-router';
import { msg, plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import {
FIELD_META_DEFAULT_VALUES,
type TCheckboxFieldMeta,
type TDateFieldMeta,
type TDropdownFieldMeta,
type TEmailFieldMeta,
type TFieldMetaSchema,
type TInitialsFieldMeta,
type TNameFieldMeta,
type TNumberFieldMeta,
type TRadioFieldMeta,
type TSignatureFieldMeta,
type TTextFieldMeta,
import type { TDetectedFormField } from '@documenso/lib/types/document-analysis';
import type {
TCheckboxFieldMeta,
TDateFieldMeta,
TDropdownFieldMeta,
TEmailFieldMeta,
TFieldMetaSchema,
TInitialsFieldMeta,
TNameFieldMeta,
TNumberFieldMeta,
TRadioFieldMeta,
TSignatureFieldMeta,
TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
@@ -46,7 +44,6 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
@@ -56,6 +53,51 @@ const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
);
const detectFormFieldsInDocument = async (params: {
envelopeId: string;
onProgress: (current: number, total: number) => void;
}): Promise<{
fieldsPerPage: Map<number, TDetectedFormField[]>;
errors: Map<number, Error>;
}> => {
const { envelopeId, onProgress } = params;
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
const errors = new Map<number, Error>();
try {
onProgress(0, 1);
const response = await fetch('/api/ai/detect-fields', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ envelopeId }),
credentials: 'include',
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Field detection failed: ${response.statusText} - ${errorText}`);
}
const detectedFields: TDetectedFormField[] = await response.json();
for (const field of detectedFields) {
if (!fieldsPerPage.has(field.pageNumber)) {
fieldsPerPage.set(field.pageNumber, []);
}
fieldsPerPage.get(field.pageNumber)!.push(field);
}
onProgress(1, 1);
} catch (error) {
errors.set(0, error instanceof Error ? error : new Error(String(error)));
}
return { fieldsPerPage, errors };
};
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.SIGNATURE]: msg`Signature Settings`,
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
@@ -73,17 +115,19 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
export const EnvelopeEditorFieldsPage = () => {
const [searchParams] = useSearchParams();
const team = useCurrentTeam();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { _ } = useLingui();
const { t } = useLingui();
const { toast } = useToast();
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
const [isDetectingFields, setIsAutoAddingFields] = useState(false);
const [processingProgress, setProcessingProgress] = useState<{
current: number;
total: number;
} | null>(null);
const [hasAutoPlacedFields, setHasAutoPlacedFields] = useState(false);
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
@@ -97,38 +141,13 @@ export const EnvelopeEditorFieldsPage = () => {
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
// Todo: Envelopes - Clean up console logs.
if (!isMetaSame) {
console.log('TRIGGER UPDATE');
editorFields.updateFieldByFormId(selectedField.formId, {
fieldMeta,
});
} else {
console.log('DATA IS SAME, NO UPDATE');
}
};
const onFieldDetectionComplete = (fields: NormalizedFieldWithContext[]) => {
for (const field of fields) {
editorFields.addField({
height: field.height,
width: field.width,
positionX: field.positionX,
positionY: field.positionY,
type: field.type,
envelopeItemId: field.envelopeItemId,
recipientId: field.recipientId,
page: field.pageNumber,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[field.type]),
});
}
setIsAiFieldDialogOpen(false);
};
/**
* Set the selected recipient to the first recipient in the envelope.
*/
useEffect(() => {
const firstSelectableRecipient = envelope.recipients.find(
(recipient) =>
@@ -138,26 +157,128 @@ export const EnvelopeEditorFieldsPage = () => {
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
}, []);
const onDetectClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
useEffect(() => {
if (hasAutoPlacedFields || !currentEnvelopeItem) {
return;
}
setIsAiFieldDialogOpen(true);
};
const storageKey = `autoPlaceFields_${envelope.id}`;
const storedData = sessionStorage.getItem(storageKey);
const onAiFeaturesEnabled = () => {
void revalidate().then(() => {
setIsAiEnableDialogOpen(false);
setIsAiFieldDialogOpen(true);
});
};
if (!storedData) {
return;
}
sessionStorage.removeItem(storageKey);
setHasAutoPlacedFields(true);
try {
const { fields: detectedFields, recipientCount } = JSON.parse(storedData) as {
fields: TDetectedFormField[];
recipientCount: number;
};
let totalAdded = 0;
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
for (const field of detectedFields) {
if (!fieldsPerPage.has(field.pageNumber)) {
fieldsPerPage.set(field.pageNumber, []);
}
fieldsPerPage.get(field.pageNumber)!.push(field);
}
for (const [pageNumber, fields] of fieldsPerPage.entries()) {
for (const detected of fields) {
const { ymin, xmin, ymax, xmax } = detected.boundingBox;
const positionX = (xmin / 1000) * 100;
const positionY = (ymin / 1000) * 100;
const width = ((xmax - xmin) / 1000) * 100;
const height = ((ymax - ymin) / 1000) * 100;
const fieldType = detected.label as FieldType;
const resolvedRecipientId =
envelope.recipients.find((recipient) => recipient.id === detected.recipientId)?.id ??
editorFields.selectedRecipient?.id ??
envelope.recipients[0]?.id;
if (!resolvedRecipientId) {
console.warn('Skipping detected field because no recipient could be resolved', {
detectedRecipientId: detected.recipientId,
});
continue;
}
try {
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageNumber,
type: fieldType,
positionX,
positionY,
width,
height,
recipientId: resolvedRecipientId,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
});
totalAdded++;
} catch (error) {
console.error(`Failed to add field on page ${pageNumber}:`, error);
}
}
}
if (totalAdded > 0) {
toast({
title: t`Recipients and fields added`,
description: t`Added ${recipientCount} ${plural(recipientCount, {
one: 'recipient',
other: 'recipients',
})} and ${totalAdded} ${plural(totalAdded, { one: 'field', other: 'fields' })}`,
duration: 5000,
});
} else {
toast({
title: t`Recipients added`,
description: t`Added ${recipientCount} ${plural(recipientCount, {
one: 'recipient',
other: 'recipients',
})}. No fields were detected in the document.`,
duration: 5000,
});
}
} catch (error) {
console.error('Failed to auto-place fields:', error);
toast({
title: t`Field placement failed`,
description: t`Failed to automatically place fields. You can add them manually.`,
variant: 'destructive',
duration: 5000,
});
}
}, [
currentEnvelopeItem,
envelope.id,
envelope.recipients,
editorFields,
hasAutoPlacedFields,
t,
toast,
]);
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
<div className="relative flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
{isDetectingFields && (
<>
<div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-32" />
<div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-32" />
<div className="edge-glow edge-glow-bottom pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-32" />
<div className="edge-glow edge-glow-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-32" />
</>
)}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
@@ -250,35 +371,127 @@ export const EnvelopeEditorFieldsPage = () => {
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
variant="outline"
disabled={isDetectingFields}
onClick={async () => {
setIsAutoAddingFields(true);
setProcessingProgress(null);
try {
if (!currentEnvelopeItem) {
toast({
title: t`No document selected`,
description: t`No document selected. Please reload the page and try again.`,
variant: 'destructive',
});
return;
}
if (!currentEnvelopeItem.documentDataId) {
toast({
title: t`Document data missing`,
description: t`Document data not found. Please try reloading the page.`,
variant: 'destructive',
});
return;
}
const { fieldsPerPage, errors } = await detectFormFieldsInDocument({
envelopeId: envelope.id,
onProgress: (current, total) => {
setProcessingProgress({ current, total });
},
});
let totalAdded = 0;
for (const [pageNumber, detectedFields] of fieldsPerPage.entries()) {
for (const detected of detectedFields) {
const { ymin, xmin, ymax, xmax } = detected.boundingBox;
const positionX = (xmin / 1000) * 100;
const positionY = (ymin / 1000) * 100;
const width = ((xmax - xmin) / 1000) * 100;
const height = ((ymax - ymin) / 1000) * 100;
const fieldType = detected.label as FieldType;
const resolvedRecipientId =
envelope.recipients.find(
(recipient) => recipient.id === detected.recipientId,
)?.id ??
editorFields.selectedRecipient?.id ??
envelope.recipients[0]?.id;
if (!resolvedRecipientId) {
console.warn(
'Skipping detected field because no recipient could be resolved',
{
detectedRecipientId: detected.recipientId,
},
);
continue;
}
try {
editorFields.addField({
envelopeItemId: currentEnvelopeItem.id,
page: pageNumber,
type: fieldType,
positionX,
positionY,
width,
height,
recipientId: resolvedRecipientId,
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
});
totalAdded++;
} catch (error) {
console.error(`Failed to add field on page ${pageNumber}:`, error);
}
}
}
const successfulPages = fieldsPerPage.size;
const failedPages = errors.size;
if (totalAdded > 0) {
let description = t`Added ${totalAdded} fields`;
if (fieldsPerPage.size > 1) {
description = t`Added ${totalAdded} fields across ${successfulPages} pages`;
}
if (failedPages > 0) {
description = t`Added ${totalAdded} fields across ${successfulPages} pages. ${failedPages} pages failed.`;
}
toast({
title: t`Fields added`,
description,
});
} else if (failedPages > 0) {
toast({
title: t`Field detection failed`,
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
variant: 'destructive',
});
} else {
toast({
title: t`No fields detected`,
description: t`No fields were detected in the document`,
});
}
} catch (error) {
toast({
title: t`Processing error`,
description: t`An unexpected error occurred while processing pages.`,
variant: 'destructive',
});
} finally {
setIsAutoAddingFields(false);
setProcessingProgress(null);
}
}}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
{isDetectingFields ? <Trans>Processing...</Trans> : <Trans>Auto add fields</Trans>}
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</section>
{/* Field details section. */}
@@ -320,7 +533,7 @@ export const EnvelopeEditorFieldsPage = () => {
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
<h3 className="text-sm font-semibold">
{_(FieldSettingsTypeTranslations[selectedField.type])}
{t(FieldSettingsTypeTranslations[selectedField.type])}
</h3>
{match(selectedField.type)
@@ -30,11 +30,18 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
export default function EnvelopeEditorHeader() {
const { t } = useLingui();
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
useCurrentEnvelopeEditor();
const {
envelope,
isDocument,
isTemplate,
updateEnvelope,
autosaveError,
relativePath,
editorFields,
} = useCurrentEnvelopeEditor();
return (
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
<nav className="bg-background border-border w-full border-b px-4 py-3 md:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to="/">
@@ -140,6 +147,10 @@ export default function EnvelopeEditorHeader() {
{isDocument && (
<>
<EnvelopeDistributeDialog
envelope={{
...envelope,
fields: editorFields.localFields,
}}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
@@ -8,13 +8,12 @@ import {
type SensorAPI,
} from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod';
import { plural } from '@lingui/core/macro';
import { msg } 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 { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual, prop, sortBy } from 'remeda';
import { z } from 'zod';
@@ -23,12 +22,10 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
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 { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
@@ -63,16 +60,15 @@ import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
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,
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -89,59 +85,13 @@ export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { user } = useSession();
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
// AI recipient detection dialog state
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
const { revalidate } = useRevalidator();
const onAiDialogOpenChange = (open: boolean) => {
if (open && !team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
setIsAiDialogOpen(false);
return;
}
setIsAiDialogOpen(open);
if (!open && searchParams.get('ai') === 'true') {
setSearchParams(
(prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete('ai');
return newParams;
},
{ replace: true },
);
}
};
const onDetectRecipientsClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
}
setIsAiDialogOpen(true);
};
const onAiFeaturesEnabled = () => {
void revalidate().then(() => {
setIsAiEnableDialogOpen(false);
setIsAiDialogOpen(true);
});
};
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
@@ -251,13 +201,12 @@ export const EnvelopeEditorRecipientForm = () => {
keyName: 'nativeId',
});
const emptySignerIndex = watchedSigners.findIndex(
(signer) =>
!signer.name &&
!signer.email &&
envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
const emptySigners = useCallback(
() => form.getValues('signers').filter((signer) => signer.email === ''),
[form],
);
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
);
@@ -295,77 +244,6 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
const currentSigners = form.getValues('signers');
let nextSigningOrder =
currentSigners.length > 0
? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1
: 1;
// If the only signer is the default empty signer lets just replace it with the detected recipients
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
form.setValue(
'signers',
detectedRecipients.map((recipient, index) => ({
formId: nanoid(12),
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth: [],
signingOrder: index + 1,
})),
{
shouldValidate: true,
shouldDirty: true,
},
);
return;
}
for (const recipient of detectedRecipients) {
const emailExists = currentSigners.some(
(s) => s.email.toLowerCase() === recipient.email.toLowerCase(),
);
const nameExists = currentSigners.some(
(s) => s.name.toLowerCase() === recipient.name.toLowerCase(),
);
if ((emailExists && recipient.email) || (nameExists && recipient.name)) {
continue;
}
currentSigners.push({
formId: nanoid(12),
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth: [],
signingOrder: nextSigningOrder,
});
nextSigningOrder += 1;
}
form.setValue('signers', normalizeSigningOrders(currentSigners), {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: plural(detectedRecipients.length, {
one: `Recipient added`,
other: `Recipients added`,
}),
description: plural(detectedRecipients.length, {
one: `# recipient have been added from AI detection.`,
other: `# recipients have been added from AI detection.`,
}),
});
};
const onRemoveSigner = (index: number) => {
const signer = signers[index];
@@ -428,14 +306,8 @@ export const EnvelopeEditorRecipientForm = () => {
index: number,
suggestion: RecipientAutoCompleteOption,
) => {
setValue(`signers.${index}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.email`, suggestion.email);
setValue(`signers.${index}.name`, suggestion.name || '');
};
const onDragEnd = useCallback(
@@ -588,7 +460,21 @@ export const EnvelopeEditorRecipientForm = () => {
return;
}
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
const formValueSigners = formValues.signers || [];
// Remove the last signer if it's empty.
const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
if (i === formValueSigners.length - 1 && signer.email === '') {
return false;
}
return true;
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
...formValues,
signers: nonEmptyRecipients,
});
if (!validatedFormValues.success) {
return;
@@ -657,28 +543,6 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<div className="flex flex-row items-center space-x-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
<Button
variant="outline"
className="flex flex-row items-center"
@@ -706,7 +570,7 @@ export const EnvelopeEditorRecipientForm = () => {
<CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
{organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center">
<Checkbox
@@ -754,7 +618,9 @@ export const EnvelopeEditorRecipientForm = () => {
});
}
}}
disabled={isSubmitting || hasDocumentBeenSent}
disabled={
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
}
/>
</FormControl>
@@ -768,7 +634,7 @@ export const EnvelopeEditorRecipientForm = () => {
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-help text-muted-foreground">
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
@@ -813,7 +679,7 @@ export const EnvelopeEditorRecipientForm = () => {
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-1 cursor-help text-muted-foreground">
<span className="text-muted-foreground ml-1 cursor-help">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
@@ -850,7 +716,7 @@ export const EnvelopeEditorRecipientForm = () => {
>
{signers.map((signer, index) => (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
key={`${signer.id}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
@@ -866,7 +732,7 @@ export const EnvelopeEditorRecipientForm = () => {
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'pointer-events-none rounded-md bg-widget-foreground pt-2':
'bg-widget-foreground pointer-events-none rounded-md pt-2':
snapshot.isDragging,
})}
>
@@ -940,7 +806,7 @@ export const EnvelopeEditorRecipientForm = () => {
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
@@ -994,7 +860,7 @@ export const EnvelopeEditorRecipientForm = () => {
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
placeholder={t`Name`}
{...field}
disabled={
snapshot.isDragging ||
@@ -1126,20 +992,6 @@ export const EnvelopeEditorRecipientForm = () => {
onOpenChange={setShowSigningOrderConfirmation}
onConfirm={handleSigningOrderDisable}
/>
<AiRecipientDetectionDialog
open={isAiDialogOpen}
onOpenChange={onAiDialogOpenChange}
onComplete={onAiDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</CardContent>
</Card>
);
@@ -687,10 +687,8 @@ export const EnvelopeEditorSettingsDialog = ({
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
<Trans>Reply To Email</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
@@ -728,9 +726,8 @@ export const EnvelopeEditorSettingsDialog = ({
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Trans>Message</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
@@ -1,12 +1,11 @@
import { useMemo, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { DropResult } from '@hello-pangea/dnd';
import { msg, plural } from '@lingui/core/macro';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
import { X } from 'lucide-react';
import { FileWarningIcon, GripVerticalIcon, Loader2, X } from 'lucide-react';
import { ErrorCode as DropzoneErrorCode, type FileRejection } from 'react-dropzone';
import { Link } from 'react-router';
@@ -174,7 +173,6 @@ export const EnvelopeEditorUploadPage = () => {
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
});
// Reset editor fields.
editorFields.resetForm(fieldsWithoutDeletedItem);
};
@@ -226,12 +224,7 @@ export const EnvelopeEditorUploadPage = () => {
}
if (maximumEnvelopeItemCount <= localFiles.length) {
return msg({
message: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
});
return msg`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`;
}
return null;
@@ -245,10 +238,7 @@ export const EnvelopeEditorUploadPage = () => {
if (maxItemsReached) {
toast({
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
@@ -152,30 +152,30 @@ export default function EnvelopeEditor() {
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
return (
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
<div className="dark:bg-background h-screen w-screen bg-gray-50">
<EnvelopeEditorHeader />
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
<div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4">
{/* Left section step selector. */}
<div className="px-4">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<div className="bg-muted relative my-4 h-[4px] rounded-md">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
className="bg-documenso absolute inset-y-0 left-0"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
@@ -219,7 +219,7 @@ export default function EnvelopeEditor() {
>
{t(step.title)}
</div>
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
<div className="text-muted-foreground text-xs">{t(step.description)}</div>
</div>
</div>
</div>
@@ -232,7 +232,7 @@ export default function EnvelopeEditor() {
{/* Quick Actions. */}
<div className="space-y-3 px-4">
<h4 className="text-sm font-semibold text-foreground">
<h4 className="text-foreground text-sm font-semibold">
<Trans>Quick Actions</Trans>
</h4>
<EnvelopeEditorSettingsDialog
@@ -246,6 +246,10 @@ export default function EnvelopeEditor() {
{isDocument && (
<EnvelopeDistributeDialog
envelope={{
...envelope,
fields: editorFields.localFields,
}}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start">
@@ -1,7 +1,5 @@
import { useCallback, useState } from 'react';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
@@ -41,15 +39,8 @@ export const EnvelopeRecipientSelector = ({
fields,
align = 'start',
}: EnvelopeRecipientSelectorProps) => {
const { i18n } = useLingui();
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
return (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
@@ -58,7 +49,7 @@ export const EnvelopeRecipientSelector = ({
variant="outline"
role="combobox"
className={cn(
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
@@ -68,12 +59,16 @@ export const EnvelopeRecipientSelector = ({
className,
)}
>
{selectedRecipient && (
{selectedRecipient?.email && (
<span className="flex-1 truncate text-left">
{getRecipientLabel(selectedRecipient)}
{selectedRecipient?.name} ({selectedRecipient?.email})
</span>
)}
{!selectedRecipient?.email && (
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
@@ -110,7 +105,7 @@ export const EnvelopeRecipientSelectorCommand = ({
fields,
placeholder,
}: EnvelopeRecipientSelectorCommandProps) => {
const { t, i18n } = useLingui();
const { t } = useLingui();
const recipientsByRole = useCallback(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
@@ -159,11 +154,6 @@ export const EnvelopeRecipientSelectorCommand = ({
[fields, recipients],
);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
return (
<Command
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
@@ -172,21 +162,21 @@ export const EnvelopeRecipientSelectorCommand = ({
<CommandInput placeholder={placeholder} />
<CommandEmpty>
<span className="inline-block px-4 text-muted-foreground">
<span className="text-muted-foreground inline-block px-4">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
<Trans>No recipients with this role</Trans>
</div>
@@ -215,12 +205,18 @@ export const EnvelopeRecipientSelectorCommand = ({
}}
>
<span
className={cn('truncate text-foreground/70', {
className={cn('text-foreground/70 truncate', {
'text-foreground/80': recipient.id === selectedRecipient?.id,
'opacity-50': isRecipientDisabled(recipient.id),
})}
>
{getRecipientLabel(recipient)}
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
</span>
<div className="ml-auto flex items-center justify-center">
@@ -238,7 +234,7 @@ export const EnvelopeRecipientSelectorCommand = ({
<Info className="z-50 ml-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
This document has already been sent to this recipient. You can no longer
edit this recipient.
@@ -254,22 +250,3 @@ export const EnvelopeRecipientSelectorCommand = ({
</Command>
);
};
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`;
}
if (recipient.name) {
return recipient.name;
}
if (recipient.email) {
return recipient.email;
}
// Since objects are basically pointers we can use `indexOf` rather than `findIndex`
const index = recipients.indexOf(recipient);
return i18n._(msg`Recipient ${index + 1}`);
};
@@ -41,7 +41,7 @@ export default function EnvelopeSignerForm() {
if (recipient.role === RecipientRole.ASSISTANT) {
return (
<fieldset className="embed--DocumentWidgetForm rounded-2xl border-border sm:border sm:p-3 dark:bg-background">
<fieldset className="embed--DocumentWidgetForm dark:bg-background border-border rounded-2xl sm:border sm:p-3">
<RadioGroup
className="gap-0 space-y-2 shadow-none sm:space-y-3"
value={selectedAssistantRecipient?.id?.toString()}
@@ -54,7 +54,7 @@ export default function EnvelopeSignerForm() {
.map((r) => (
<div
key={r.id}
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -69,15 +69,15 @@ export default function EnvelopeSignerForm() {
{r.name}
{r.id === recipient.id && (
<span className="ml-2 text-muted-foreground">
<span className="text-muted-foreground ml-2">
<Trans>(You)</Trans>
</span>
)}
</Label>
<p className="text-xs text-muted-foreground">{r.email}</p>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-xs leading-[inherit] text-muted-foreground">
<div className="text-muted-foreground text-xs leading-[inherit]">
<Plural
value={assistantFields.filter((field) => field.recipientId === r.id).length}
one="# field"
@@ -103,7 +103,7 @@ export default function EnvelopeSignerForm() {
<Input
type="text"
id="full-name"
className="mt-2 bg-background"
className="bg-background mt-2"
value={fullName}
disabled={isNameLocked}
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
@@ -119,7 +119,6 @@ export default function EnvelopeSignerForm() {
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
fullName={fullName}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
@@ -374,7 +374,6 @@ export default function EnvelopeSignerPageRenderer() {
.with({ type: FieldType.SIGNATURE }, (field) => {
handleSignatureFieldClick({
field,
fullName,
signature,
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
@@ -80,14 +80,12 @@ export const EnvelopeSignerCompleteDialog = () => {
const handleOnCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
recipientDetails?: { name: string; email: string },
) => {
try {
await completeDocument({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions,
recipientOverride: recipientDetails,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
});
@@ -207,30 +205,21 @@ export const EnvelopeSignerCompleteDialog = () => {
}
};
const recipientPayload = useMemo(() => {
const directTemplatePayload = useMemo(() => {
if (!isDirectTemplate) {
return {
name:
recipient.name ||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
'',
email:
recipient.email ||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
'',
};
return;
}
return {
name: fullName,
email: email,
};
}, [email, fullName, isDirectTemplate, recipient.email, recipient.name, recipient.fields]);
}, [email, fullName, isDirectTemplate]);
return (
<DocumentSigningCompleteDialog
isSubmitting={isPending}
recipientPayload={recipientPayload}
directTemplatePayload={directTemplatePayload}
onSignatureComplete={
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
}
@@ -241,7 +230,6 @@ export const EnvelopeSignerCompleteDialog = () => {
allowDictateNextSigner={Boolean(
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
)}
disableNameInput={!isDirectTemplate && recipient.name !== ''}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
@@ -1,8 +1,7 @@
import { type ReactNode, useState } from 'react';
import { plural } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { Loader } from 'lucide-react';
import {
@@ -28,7 +27,13 @@ import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-rou
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
import { useCurrentTeam } from '~/providers/team';
import {
type RecipientForCreation,
detectRecipientsInDocument,
ensureRecipientEmails,
} from '~/utils/detect-document-recipients';
export interface EnvelopeDropZoneWrapperProps {
children: ReactNode;
@@ -53,6 +58,10 @@ export const EnvelopeDropZoneWrapper = ({
const organisation = useCurrentOrganisation();
const [isLoading, setIsLoading] = useState(false);
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
const userTimezone =
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
@@ -61,6 +70,7 @@ export const EnvelopeDropZoneWrapper = ({
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation();
const isUploadDisabled = remaining.documents === 0 || !user.emailVerified;
@@ -109,33 +119,32 @@ export const EnvelopeDropZoneWrapper = ({
documentId: id,
timestamp: new Date().toISOString(),
});
setUploadedDocumentId(id);
setPendingRecipients(null);
setShouldNavigateAfterPromptClose(true);
setShowExtractionPrompt(true);
} else {
const pathPrefix = formatTemplatesPath(team.url);
await navigate(`${pathPrefix}/${id}/edit`);
}
const pathPrefix =
type === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => t`You have reached the limit of the number of files per envelope.`,
() => t`You have reached the limit of the number of files per envelope`,
)
.otherwise(() => t`An error occurred during upload.`);
toast({
title: t`Error`,
title: t`Upload failed`,
description: errorMessage,
variant: 'destructive',
duration: 7500,
@@ -156,10 +165,7 @@ export const EnvelopeDropZoneWrapper = ({
if (maxItemsReached) {
toast({
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
@@ -167,7 +173,6 @@ export const EnvelopeDropZoneWrapper = ({
return;
}
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
const { file, errors } = fileRejections[0];
if (!errors.length) {
@@ -207,6 +212,116 @@ export const EnvelopeDropZoneWrapper = ({
variant: 'destructive',
});
};
const navigateToEnvelopeEditor = () => {
if (!uploadedDocumentId) {
return;
}
const pathPrefix = formatDocumentsPath(team.url);
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit`);
};
const handleStartRecipientDetection = async () => {
if (!uploadedDocumentId) {
return;
}
try {
const recipients = await detectRecipientsInDocument(uploadedDocumentId);
if (recipients.length === 0) {
toast({
title: t`No recipients detected`,
description: t`You can add recipients manually in the editor`,
duration: 5000,
});
setShouldNavigateAfterPromptClose(true);
setShowExtractionPrompt(false);
navigateToEnvelopeEditor();
return;
}
const recipientsWithEmails = ensureRecipientEmails(recipients, uploadedDocumentId);
setPendingRecipients(recipientsWithEmails);
setShouldNavigateAfterPromptClose(false);
} catch (error) {
if (!(error instanceof Error && error.message === 'NO_RECIPIENTS_DETECTED')) {
const parsedError = AppError.parseError(error);
toast({
title: t`Failed to detect recipients`,
description: parsedError.userMessage || t`You can add recipients manually in the editor`,
variant: 'destructive',
duration: 7500,
});
}
throw error;
}
};
const handleSkipRecipientDetection = () => {
setShouldNavigateAfterPromptClose(true);
setShowExtractionPrompt(false);
navigateToEnvelopeEditor();
};
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
if (!uploadedDocumentId) {
return;
}
try {
await createRecipients({
envelopeId: uploadedDocumentId,
data: recipientsToCreate,
});
toast({
title: t`Recipients added`,
description: t`Successfully detected ${recipientsToCreate.length} ${plural(
recipientsToCreate.length,
{
one: 'recipient',
other: 'recipients',
},
)}`,
duration: 5000,
});
setShowExtractionPrompt(false);
setPendingRecipients(null);
navigateToEnvelopeEditor();
} catch (error) {
const parsedError = AppError.parseError(error);
toast({
title: t`Failed to add recipients`,
description: parsedError.userMessage || t`Please review the recipients and try again`,
variant: 'destructive',
duration: 7500,
});
throw error;
}
};
const handlePromptDialogOpenChange = (open: boolean) => {
setShowExtractionPrompt(open);
if (open) {
setShouldNavigateAfterPromptClose(true);
return;
}
if (!open && shouldNavigateAfterPromptClose) {
navigateToEnvelopeEditor();
}
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
@@ -226,9 +341,9 @@ export const EnvelopeDropZoneWrapper = ({
{children}
{isDragActive && (
<div className="fixed left-0 top-0 z-[9999] h-full w-full bg-muted/60 backdrop-blur-[4px]">
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-2xl font-semibold text-foreground">
<h2 className="text-foreground text-2xl font-semibold">
{type === EnvelopeType.DOCUMENT ? (
<Trans>Upload Document</Trans>
) : (
@@ -236,7 +351,7 @@ export const EnvelopeDropZoneWrapper = ({
)}
</h2>
<p className="text-md mt-4 text-muted-foreground">
<p className="text-muted-foreground text-md mt-4">
<Trans>Drag and drop your PDF file here</Trans>
</p>
@@ -253,7 +368,7 @@ export const EnvelopeDropZoneWrapper = ({
team?.id === undefined &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<p className="mt-4 text-sm text-muted-foreground/80">
<p className="text-muted-foreground/80 mt-4 text-sm">
<Trans>
{remaining.documents} of {quota.documents} documents remaining this month.
</Trans>
@@ -264,15 +379,24 @@ export const EnvelopeDropZoneWrapper = ({
)}
{isLoading && (
<div className="absolute inset-0 z-50 bg-muted/30 backdrop-blur-[2px]">
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="h-12 w-12 animate-spin text-primary" />
<p className="mt-8 font-medium text-foreground">
<Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium">
<Trans>Uploading</Trans>
</p>
</div>
</div>
)}
<RecipientDetectionPromptDialog
open={showExtractionPrompt}
onOpenChange={handlePromptDialogOpenChange}
onAccept={handleStartRecipientDetection}
onSkip={handleSkipRecipientDetection}
recipients={pendingRecipients}
onRecipientsSubmit={handleRecipientsConfirm}
/>
</div>
);
};
@@ -27,7 +27,14 @@ import {
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecipientDetectionPromptDialog } from '~/components/dialogs/recipient-detection-prompt-dialog';
import { useCurrentTeam } from '~/providers/team';
import { detectFieldsInDocument } from '~/utils/detect-document-fields';
import {
type RecipientForCreation,
detectRecipientsInDocument,
ensureRecipientEmails,
} from '~/utils/detect-document-recipients';
export type EnvelopeUploadButtonProps = {
className?: string;
@@ -35,9 +42,6 @@ export type EnvelopeUploadButtonProps = {
folderId?: string;
};
/**
* Upload an envelope
*/
export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUploadButtonProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -55,8 +59,14 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
const { quota, remaining, refreshLimits, maximumEnvelopeItemCount } = useLimits();
const [isLoading, setIsLoading] = useState(false);
const [showExtractionPrompt, setShowExtractionPrompt] = useState(false);
const [uploadedDocumentId, setUploadedDocumentId] = useState<string | null>(null);
const [pendingRecipients, setPendingRecipients] = useState<RecipientForCreation[] | null>(null);
const [shouldNavigateAfterPromptClose, setShouldNavigateAfterPromptClose] = useState(true);
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
const { mutateAsync: createEnvelope } = trpc.envelope.create.useMutation();
const { mutateAsync: createRecipients } = trpc.envelope.recipient.createMany.useMutation();
const disabledMessage = useMemo(() => {
if (organisation.subscription && remaining.documents === 0) {
@@ -108,37 +118,45 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
if (type === EnvelopeType.DOCUMENT) {
setUploadedDocumentId(id);
setPendingRecipients(null);
setShouldNavigateAfterPromptClose(true);
setShowExtractionPrompt(true);
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
toast({
title: t`Document uploaded`,
description: t`Your document has been uploaded successfully.`,
duration: 5000,
});
} else {
await navigate(`${pathPrefix}/${id}/edit`);
toast({
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
description:
type === EnvelopeType.DOCUMENT
? t`Your document has been uploaded successfully.`
: t`Your template has been uploaded successfully.`,
duration: 5000,
});
toast({
title: t`Template uploaded`,
description: t`Your template has been uploaded successfully.`,
duration: 5000,
});
}
} catch (err) {
const error = AppError.parseError(err);
console.error(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => t`You have reached the limit of the number of files per envelope.`,
() => t`You have reached the limit of the number of files per envelope`,
)
.otherwise(() => t`An error occurred while uploading your document.`);
toast({
title: t`Error`,
title: t`Upload failed`,
description: errorMessage,
variant: 'destructive',
duration: 7500,
@@ -155,10 +173,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
if (maxItemsReached) {
toast({
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
@@ -174,6 +189,181 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
});
};
const navigateToEnvelopeEditor = () => {
if (!uploadedDocumentId) {
return;
}
const pathPrefix = formatDocumentsPath(team.url);
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit`);
};
const handleStartRecipientDetection = async () => {
if (!uploadedDocumentId) {
return;
}
try {
const recipients = await detectRecipientsInDocument(uploadedDocumentId);
if (recipients.length === 0) {
toast({
title: t`No recipients detected`,
description: t`You can add recipients manually in the editor`,
duration: 5000,
});
setShouldNavigateAfterPromptClose(true);
setShowExtractionPrompt(false);
navigateToEnvelopeEditor();
return;
}
const recipientsWithEmails = ensureRecipientEmails(recipients, uploadedDocumentId);
setPendingRecipients(recipientsWithEmails);
setShouldNavigateAfterPromptClose(false);
} catch (err) {
const error = AppError.parseError(err);
// Only show toast if this wasn't a "no recipients found" case
if (error.code !== 'NO_RECIPIENTS_DETECTED') {
toast({
title: t`Failed to analyze recipients`,
description: error.userMessage || t`You can add recipients manually in the editor`,
variant: 'destructive',
duration: 7500,
});
}
throw error;
}
};
const handleSkipRecipientDetection = () => {
setShouldNavigateAfterPromptClose(true);
setShowExtractionPrompt(false);
navigateToEnvelopeEditor();
};
const handleRecipientsConfirm = async (recipientsToCreate: RecipientForCreation[]) => {
if (!uploadedDocumentId) {
return;
}
try {
await createRecipients({
envelopeId: uploadedDocumentId,
data: recipientsToCreate,
});
toast({
title: t`Recipients added`,
description: t`Successfully detected ${recipientsToCreate.length} ${plural(
recipientsToCreate.length,
{
one: 'recipient',
other: 'recipients',
},
)}`,
duration: 5000,
});
setShowExtractionPrompt(false);
setPendingRecipients(null);
navigateToEnvelopeEditor();
} catch (err) {
const error = AppError.parseError(err);
toast({
title: t`Failed to add recipients`,
description: error.userMessage || t`Please review the recipients and try again`,
variant: 'destructive',
duration: 7500,
});
// Error is handled, dialog stays open for retry
}
};
const handleAutoAddFields = async (recipientsToCreate: RecipientForCreation[]) => {
if (!uploadedDocumentId) {
return;
}
setIsAutoAddingFields(true);
try {
await createRecipients({
envelopeId: uploadedDocumentId,
data: recipientsToCreate,
});
let detectedFields;
try {
detectedFields = await detectFieldsInDocument(uploadedDocumentId);
} catch (error) {
console.error('Field detection failed:', error);
toast({
title: t`Field detection failed`,
description: t`Recipients added successfully, but field detection encountered an error. You can add fields manually.`,
variant: 'destructive',
duration: 7500,
});
setShowExtractionPrompt(false);
setPendingRecipients(null);
setIsAutoAddingFields(false);
const pathPrefix = formatDocumentsPath(team.url);
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit?step=addFields`);
return;
}
if (detectedFields.length > 0) {
sessionStorage.setItem(
`autoPlaceFields_${uploadedDocumentId}`,
JSON.stringify({
fields: detectedFields,
recipientCount: recipientsToCreate.length,
}),
);
}
setShowExtractionPrompt(false);
setPendingRecipients(null);
setIsAutoAddingFields(false);
const pathPrefix = formatDocumentsPath(team.url);
void navigate(`${pathPrefix}/${uploadedDocumentId}/edit?step=addFields`);
} catch (err) {
const error = AppError.parseError(err);
toast({
title: t`Failed to add recipients`,
description: error.userMessage || t`Please try again`,
variant: 'destructive',
duration: 7500,
});
setIsAutoAddingFields(false);
}
};
const handlePromptDialogOpenChange = (open: boolean) => {
setShowExtractionPrompt(open);
if (open) {
setShouldNavigateAfterPromptClose(true);
return;
}
if (!open && shouldNavigateAfterPromptClose) {
navigateToEnvelopeEditor();
}
};
return (
<div className={cn('relative', className)}>
<TooltipProvider>
@@ -206,6 +396,17 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
)}
</Tooltip>
</TooltipProvider>
<RecipientDetectionPromptDialog
open={showExtractionPrompt}
onOpenChange={handlePromptDialogOpenChange}
onAccept={handleStartRecipientDetection}
onSkip={handleSkipRecipientDetection}
recipients={pendingRecipients}
onRecipientsSubmit={handleRecipientsConfirm}
onAutoAddFields={handleAutoAddFields}
isProcessingRecipients={isAutoAddingFields}
/>
</div>
);
};
@@ -15,6 +15,7 @@ import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
import { DocumentUploadButtonLegacy } from '~/components/general/document/document-upload-button-legacy';
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team';
@@ -69,7 +70,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
<div>
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div
className="flex flex-1 items-center text-sm font-medium text-muted-foreground hover:text-muted-foreground/80"
className="text-muted-foreground hover:text-muted-foreground/80 flex flex-1 items-center text-sm font-medium"
data-testid="folder-grid-breadcrumbs"
>
<Link to={formatRootPath()} className="flex items-center">
@@ -99,9 +100,10 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
<div className="flex gap-4 sm:flex-row sm:justify-end">
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
{/* If you delete this, delete the component as well. */}
{organisation.organisationClaim.flags.allowLegacyEnvelopes && (
<DocumentUploadButtonLegacy type={type} />
{type === FolderType.DOCUMENT ? (
<DocumentUploadButtonLegacy /> // If you delete this, delete the component as well.
) : (
<TemplateCreateDialog folderId={parentId ?? undefined} /> // If you delete this, delete the component as well.
)}
<FolderCreateDialog type={type} />
@@ -111,7 +113,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
{isPending ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-full rounded-lg border border-border bg-card px-4 py-5">
<div key={index} className="border-border bg-card h-full rounded-lg border px-4 py-5">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded" />
<div className="flex w-full items-center justify-between">
@@ -192,7 +194,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
{foldersData.folders.length > 12 && (
<div className="mt-2 flex items-center justify-center">
<Link
className="text-sm font-medium text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground text-sm font-medium"
to={formatViewAllFoldersPath()}
>
View all folders
@@ -83,8 +83,8 @@ export const StackAvatarsWithTooltip = ({
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -107,8 +107,8 @@ export const StackAvatarsWithTooltip = ({
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -51,7 +51,7 @@ export const AdminDashboardUsersTable = ({
const columns = useMemo(() => {
return [
{
header: _(msg`ID`),
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
@@ -57,7 +57,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
const columns = useMemo(() => {
return [
{
header: _(msg`ID`),
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
@@ -113,11 +113,7 @@ export const AdminOrganisationsTable = ({
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}
>
{isPaid ? (
<Trans context="Subscription status">Paid</Trans>
) : (
<Trans context="Subscription status">Free</Trans>
)}
{isPaid ? t`Paid` : t`Free`}
</div>
);
},
@@ -135,7 +131,7 @@ export const AdminOrganisationsTable = ({
<ExternalLinkIcon className="h-4 w-4" />
</Link>
) : (
<Trans>None</Trans>
'None'
),
},
{
@@ -96,11 +96,11 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
cell: ({ row }) => <span>{formatDocumentAuditLogAction(_, row.original).description}</span>,
},
{
header: _(msg`IP Address`),
header: 'IP Address',
accessorKey: 'ipAddress',
},
{
header: _(msg`Browser`),
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
@@ -104,7 +104,7 @@ export const SettingsSecurityActivityTable = () => {
},
},
{
header: _(msg`IP Address`),
header: 'IP Address',
accessorKey: 'ipAddress',
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
},
@@ -28,19 +28,19 @@ export const UserBillingOrganisationsTable = () => {
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
return match(status)
.with(SubscriptionStatus.ACTIVE, () => ({
label: t({ message: `Active`, context: `Subscription status` }),
label: t`Active`,
variant: 'default' as const,
}))
.with(SubscriptionStatus.PAST_DUE, () => ({
label: t({ message: `Past Due`, context: `Subscription status` }),
label: t`Past Due`,
variant: 'warning' as const,
}))
.with(SubscriptionStatus.INACTIVE, () => ({
label: t({ message: `Inactive`, context: `Subscription status` }),
label: t`Inactive`,
variant: 'neutral' as const,
}))
.otherwise(() => ({
label: t({ message: `Free`, context: `Subscription status` }),
label: t`Free`,
variant: 'neutral' as const,
}));
};
-2
View File
@@ -10,8 +10,6 @@ import { HydratedRouter } from 'react-router/dom';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import './utils/polyfills/promise-with-resolvers';
function PosthogInit() {
const postHogConfig = extractPostHogConfig();
+2 -16
View File
@@ -46,16 +46,12 @@ export async function loader({ request }: Route.LoaderArgs) {
const { getTheme } = await themeSessionResolver(request);
const cookieHeader = request.headers.get('cookie') ?? '';
let lang: SupportedLanguageCodes = await langCookie.parse(cookieHeader);
let lang: SupportedLanguageCodes = await langCookie.parse(request.headers.get('cookie') ?? '');
if (!APP_I18N_OPTIONS.supportedLangs.includes(lang)) {
lang = extractLocaleData({ headers: request.headers }).lang;
}
const disableAnimations = cookieHeader.includes('__disable_animations=true');
let organisations = null;
if (session.isAuthenticated) {
@@ -66,7 +62,6 @@ export async function loader({ request }: Route.LoaderArgs) {
{
lang,
theme: getTheme(),
disableAnimations,
session: session.isAuthenticated
? {
user: session.user,
@@ -97,8 +92,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
}
export function LayoutContent({ children }: { children: React.ReactNode }) {
const { publicEnv, session, lang, disableAnimations, ...data } =
useLoaderData<typeof loader>() || {};
const { publicEnv, session, lang, ...data } = useLoaderData<typeof loader>() || {};
const [theme] = useTheme();
@@ -117,14 +111,6 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<meta name="google" content="notranslate" />
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
{disableAnimations && (
<style
dangerouslySetInnerHTML={{
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
}}
/>
)}
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
<script>0</script>
</head>
@@ -1,7 +1,7 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
@@ -86,7 +86,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
)}
</div>
<div className="mt-4 text-sm text-muted-foreground">
<div className="text-muted-foreground mt-4 text-sm">
<div>
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
</div>
@@ -112,8 +112,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
disabled={envelope.recipients.some(
(recipient) =>
recipient.signingStatus !== SigningStatus.SIGNED &&
recipient.signingStatus !== SigningStatus.REJECTED &&
recipient.role !== RecipientRole.CC,
recipient.signingStatus !== SigningStatus.REJECTED,
)}
onClick={() => resealDocument({ id: envelope.id })}
>
@@ -114,7 +114,7 @@ export default function AdminDocumentsPage() {
},
},
{
header: _(msg`Last updated`),
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => i18n.date(row.original.updatedAt),
},
@@ -152,6 +152,12 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
<div className="mt-5 grid grid-cols-2 gap-8">
<MonthlyActiveUsersChart title={_(msg`MAU (signed in)`)} data={monthlyActiveUsers} />
<MonthlyActiveUsersChart
title={_(msg`Cumulative MAU (signed in)`)}
data={monthlyActiveUsers}
cummulative
/>
<AdminStatsUsersWithDocumentsChart
data={monthlyUsersWithDocuments}
title={_(msg`MAU (created document)`)}
@@ -178,9 +178,7 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVerticalIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
@@ -87,9 +87,9 @@ export default function OrganisationSettingsBrandingPage() {
const settingsHeaderText = t`Branding Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general branding preferences.`
? t`Here you can set your general branding preferences`
: team
? t`Here you can set branding preferences for your team.`
? t`Here you can set branding preferences for your team`
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
return (
@@ -1,10 +1,8 @@
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
@@ -21,16 +19,9 @@ export function meta() {
return appMetaTags('Document Preferences');
}
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function OrganisationSettingsDocumentPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const { t } = useLingui();
@@ -57,7 +48,6 @@ export default function OrganisationSettingsDocumentPage() {
includeSigningCertificate,
includeAuditLog,
signatureTypes,
aiFeaturesEnabled,
} = data;
if (
@@ -66,8 +56,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat === null ||
includeSenderDetails === null ||
includeSigningCertificate === null ||
includeAuditLog === null ||
aiFeaturesEnabled === null
includeAuditLog === null
) {
throw new Error('Should not be possible.');
}
@@ -85,7 +74,6 @@ export default function OrganisationSettingsDocumentPage() {
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
aiFeaturesEnabled,
},
});
@@ -105,14 +93,14 @@ export default function OrganisationSettingsDocumentPage() {
if (isLoadingOrganisation || !organisationWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
const settingsHeaderText = t`Document Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general document preferences.`
? t`Here you can set your general document preferences`
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
return (
@@ -122,7 +110,6 @@ export default function OrganisationSettingsDocumentPage() {
<section>
<DocumentPreferencesForm
canInherit={false}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onDocumentPreferencesFormSubmit}
/>
@@ -65,7 +65,7 @@ export default function OrganisationSettingsGeneral() {
<div className="max-w-2xl">
<SettingsHeader
title={t`Email Preferences`}
subtitle={t`You can manage your email preferences here.`}
subtitle={t`You can manage your email preferences here`}
/>
<section>
@@ -1,5 +1,5 @@
import DocumentPage, { loader, meta } from '../../o.$orgUrl.settings.document';
import DocumentPage, { meta } from '../../o.$orgUrl.settings.document';
export { meta, loader };
export { meta };
export default DocumentPage;
@@ -1,8 +1,6 @@
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -19,15 +17,7 @@ export function meta() {
return appMetaTags('Document Preferences');
}
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function TeamsSettingsPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
const team = useCurrentTeam();
const { t } = useLingui();
@@ -50,7 +40,6 @@ export default function TeamsSettingsPage() {
includeSigningCertificate,
includeAuditLog,
signatureTypes,
aiFeaturesEnabled,
} = data;
await updateTeamSettings({
@@ -63,7 +52,6 @@ export default function TeamsSettingsPage() {
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
aiFeaturesEnabled,
...(signatureTypes.length === 0
? {
typedSignatureEnabled: null,
@@ -94,7 +82,7 @@ export default function TeamsSettingsPage() {
if (isLoadingTeam || !teamWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
@@ -109,7 +97,6 @@ export default function TeamsSettingsPage() {
<section>
<DocumentPreferencesForm
canInherit={true}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={teamWithSettings.teamSettings}
onFormSubmit={onDocumentPreferencesSubmit}
/>
@@ -63,7 +63,7 @@ export default function TeamEmailSettingsGeneral() {
<div className="max-w-2xl">
<SettingsHeader
title={t`Email Preferences`}
subtitle={t`You can manage your email preferences here.`}
subtitle={t`You can manage your email preferences here`}
/>
<section>
@@ -185,9 +185,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT
].filter((log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED
].filter(
@@ -248,11 +245,11 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
<div className="break-all">{recipient.email}</div>
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">{_(msg`Authentication Level`)}:</span>{' '}
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
</p>
@@ -276,13 +273,13 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
)}
{signature.signature?.typedSignature && (
<p className="text-center font-signature text-sm">
<p className="font-signature text-center text-sm">
{signature.signature?.typedSignature}
</p>
)}
</div>
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">{_(msg`Signature ID`)}:</span>{' '}
<span className="block font-mono uppercase">
{signature.secondaryId}
@@ -293,14 +290,14 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
<p className="text-muted-foreground">N/A</p>
)}
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
</span>
</p>
<p className="mt-1 text-sm text-muted-foreground print:text-xs">
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
@@ -310,22 +307,18 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
<TableCell truncate={false} className="w-[min-content] align-top">
<div className="space-y-1">
<p className="text-sm text-muted-foreground print:text-xs">
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Sent`)}:</span>{' '}
<span className="inline-block">
{logs.EMAIL_SENT[0]
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: logs.DOCUMENT_SENT[0]
? DateTime.fromJSDate(logs.DOCUMENT_SENT[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
: _(msg`Unknown`)}
</span>
</p>
<p className="text-sm text-muted-foreground print:text-xs">
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Viewed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_OPENED[0]
@@ -337,7 +330,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</p>
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
<p className="text-sm text-muted-foreground print:text-xs">
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_REJECTED[0]
@@ -348,7 +341,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</span>
</p>
) : (
<p className="text-sm text-muted-foreground print:text-xs">
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
@@ -362,7 +355,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</p>
)}
<p className="text-sm text-muted-foreground print:text-xs">
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
<span className="inline-block">
{recipient.signingStatus === SigningStatus.REJECTED
@@ -371,9 +371,8 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
to="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso
Check out Documenso.
</Link>
.
</Trans>
</p>
)}
@@ -471,9 +470,8 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
to="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso
Check out Documenso.
</Link>
.
</Trans>
</p>
)}
@@ -1,9 +1,9 @@
import satori from 'satori';
import sharp from 'sharp';
import { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/document/get-recipient-or-sender-by-share-link-slug';
import { svgToPng } from '@documenso/lib/utils/images/svg-to-png';
import type { Route } from './+types/share.$slug.opengraph';
@@ -181,7 +181,8 @@ export const loader = async ({ params }: Route.LoaderArgs) => {
},
);
const pngBuffer = await svgToPng(svg.toString());
// Convert SVG to PNG using sharp
const pngBuffer = await sharp(Buffer.from(svg)).toFormat('png').toBuffer();
return new Response(pngBuffer, {
headers: {
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
import { data, isRouteErrorResponse } from 'react-router';
@@ -126,7 +125,6 @@ export async function loader({ params }: Route.LoaderArgs) {
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
const { token, type, user, organisation } = loaderData;
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
@@ -138,12 +136,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
await navigate('/');
toast({
title: _(msg`Account link declined`),
title: 'Account link declined',
});
},
onError: (error) => {
toast({
title: _(msg`Error declining account link`),
title: 'Error declining account link',
description: error.message,
});
},
@@ -155,12 +153,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
await navigate(formatOrganisationLoginPath(organisation.url));
toast({
title: _(msg`Account linked successfully`),
title: 'Account linked successfully',
});
},
onError: (error) => {
toast({
title: _(msg`Error linking account`),
title: 'Error linking account',
description: error.message,
});
},
@@ -1,5 +1,6 @@
import sharp from 'sharp';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { loadLogo } from '@documenso/lib/utils/images/logo';
import { prisma } from '@documenso/prisma';
import type { Route } from './+types/branding.logo.organisation.$orgId';
@@ -62,12 +63,16 @@ export async function loader({ params }: Route.LoaderArgs) {
);
}
const { content, contentType } = await loadLogo(file);
const img = await sharp(file)
.toFormat('png', {
quality: 80,
})
.toBuffer();
return new Response(content, {
return new Response(Buffer.from(img), {
headers: {
'Content-Type': contentType,
'Content-Length': content.length.toString(),
'Content-Type': 'image/png',
'Content-Length': img.length.toString(),
// Stale while revalidate for 1 hours to 24 hours
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
@@ -1,6 +1,7 @@
import sharp from 'sharp';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { loadLogo } from '@documenso/lib/utils/images/logo';
import type { Route } from './+types/branding.logo.team.$teamId';
@@ -55,12 +56,16 @@ export async function loader({ params }: Route.LoaderArgs) {
);
}
const { content, contentType } = await loadLogo(file);
const img = await sharp(file)
.toFormat('png', {
quality: 80,
})
.toBuffer();
return new Response(content, {
return new Response(img, {
headers: {
'Content-Type': contentType,
'Content-Length': content.length.toString(),
'Content-Type': 'image/png',
'Content-Length': img.length.toString(),
// Stale while revalidate for 1 hours to 24 hours
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
@@ -41,9 +41,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
const token = url.searchParams.get('token') || '';
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token, scope: `documentId:${id}` }).catch(
() => null,
);
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Error('Invalid token');

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