Compare commits

...

81 Commits

Author SHA1 Message Date
Lucas Smith 43486d8448 v2.2.6 2025-12-09 21:11:01 +11:00
Lucas Smith 4d3d1b8d14 fix: make ai features more discoverable (#2305)
Previously you had to have explicit knowledge of the
feature and enable it in order to use AI assisted field
detection.

This surfaces it by having a secondary dialog prompting
for enablement.

Also includes a fix for CC recipients not getting marked
as signed in weird edge cases.
2025-12-09 15:30:48 +11:00
David Nguyen 0387f3c20a chore: add missing dropdown image (#2304)
## Description

Add missing dropdown image in the docs.
2025-12-09 12:37:45 +11:00
Ted Liang c5032d0c43 refactor: extract image-helpers (#2261) 2025-12-09 09:19:49 +11:00
Konrad 3bd34964cd fix(i18n): add pluralization to ai features (#2301) 2025-12-09 09:18:38 +11:00
Dailson Allves fe93b11a2c chore: update existing pt-BR translations after commit #2289 (#2300) 2025-12-09 09:17:22 +11:00
github-actions[bot] 7638faf27b chore: extract translations (#2289)
Automated translation extraction

Co-authored-by: github-actions <github-actions@documenso.com>
2025-12-08 19:20:21 +11:00
Ephraim Duncan 8fca029d96 fix: invalidate sessions on password reset and update (#2076) 2025-12-08 19:17:23 +11:00
Lucas Smith bac2bf11f4 v2.2.5 2025-12-08 14:33:00 +11:00
Lucas Smith d93b2a70a7 fix: upgrade react-email/render (#2297)
Upgrade the `@react-email/render` package to handle
suspense during renders.

We could have just swapped to `renderAsync` for the 0.0.x
version of the package but it's better to upgrade as part
of this change.

CI has been run locally and emails have been verified to
work and render as expected in our local mail trap.
2025-12-08 13:08:34 +11:00
Lucas Smith 5da915da38 fix: update server only urls to use private internal web app url (#2290)
Replaced instances of NEXT_PUBLIC_WEBAPP_URL with
NEXT_PRIVATE_INTERNAL_WEBAPP_URL
2025-12-08 12:56:41 +11:00
Ted Liang dcaecf1fc5 feat: resource restriction in presign token (#2150) 2025-12-08 12:55:54 +11:00
Ephraim Duncan f70b76d8b8 feat: add envelope audit logs endpoint (#2232) 2025-12-08 12:34:03 +11:00
David Nguyen 93137c6396 fix: translation extraction job (#2288)
## Description

Workaround until we can commit directly to main for translation
extractions

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-06 16:19:35 +11:00
Catalin Pit d058b7c705 feat: include CC role in removed recipient email check (#2285) 2025-12-06 14:20:25 +11:00
David Nguyen b51f562224 feat: add empty emails for envelopes (#2267) 2025-12-06 13:38:10 +11:00
David Nguyen f80aa4bf72 chore: optimize tests (#2280) 2025-12-06 12:59:53 +11:00
Lucas Smith 9238f759a6 v2.2.4 2025-12-05 12:23:23 +11:00
Lucas Smith 74ad6af47d chore: add docs for ai features (#2284)
Adds documentation for the recently added AI features

Includes details for how users can enable AI features for their team or
organisation

Also includes details for how self-hosters can setup their instance to
allow for AI features
2025-12-05 11:47:53 +11:00
Lucas Smith 18902ed59d fix: export loader for personal document preferences (#2283) 2025-12-05 11:22:29 +11:00
Lucas Smith 3f70082146 v2.2.3 2025-12-05 09:53:40 +11:00
Lucas Smith 31ba6d5f00 fix: polyfill promise.withResolvers (#2282)
Co-authored-by: Catalin Pit <catalinpit@gmail.com>
2025-12-04 23:33:31 +11:00
Lucas Smith c4f89a87a2 fix: use skia-canvas with pdfjs to avoid N-API errors (#2281)
Use custom CanvasFactory for pdfjs so we can continue to use
skia-canvas.
2025-12-04 23:26:08 +11:00
Ted Liang 89d6dd5b0e fix: embed authoring permission issue (#2279) 2025-12-04 15:02:50 +11:00
Lucas Smith 08a9ab3aaf v2.2.2 2025-12-04 14:50:09 +11:00
Lucas Smith e66bd422e3 chore: upgrade dependencies (#2278) 2025-12-04 14:31:30 +11:00
Lucas Smith 0f5814ff89 chore: add translations (#2259) 2025-12-04 14:01:35 +11:00
Konrad 1275a15571 fix(i18n): mark missing toast messages for translation (#2274) 2025-12-04 14:00:25 +11:00
Lucas Smith 22d99c7410 v2.2.1 2025-12-04 11:39:19 +11:00
Lucas Smith 26a36487d4 fix: pass canvas context to napi-rs/canvas (#2276) 2025-12-04 11:19:44 +11:00
Lucas Smith 2ee6b90c99 fix: add debug logging for ai streaming (#2275) 2025-12-04 10:03:29 +11:00
Lucas Smith f70e6ac50a v2.2.0 2025-12-04 00:31:11 +11:00
Lucas Smith 7a94ee3b83 feat: add ai detection for recipients and fields (#2271)
Use Gemini to handle detection of recipients and fields within
documents.

Opt in using organisation or team settings.

Replaces #2128 since the branch was cursed and would include
dependencies that weren't even in the lock file.



https://github.com/user-attachments/assets/e6cbb58f-62b9-4079-a9ae-7af5c4f2e4ec
2025-12-03 23:39:41 +11:00
Filbert Wijaya e39924714a fix: invalid email display bug when recipient suggestions on select (#2198) 2025-12-03 12:10:38 +11:00
Konrad c9604fee64 chore(i18n): change recipient invitation messages (#2172) 2025-12-03 11:55:53 +11:00
Konrad 90f8340af4 fix(i18n): add pluralization to envelope items (#2183) 2025-12-03 11:30:43 +11:00
Eesh Midha 28b8d2d415 fix: disable browser autocomplete in typed signature input (#2269) 2025-12-03 11:22:35 +11:00
Timur Ercan 978a2047d4 chore: update readme 2025-12-03 11:20:15 +11:00
Catalin Pit 0dfa953f54 feat: add external ID to use template (#2264) 2025-12-02 18:53:42 +11:00
David Nguyen 4774324e07 fix: prevent client side distribution when missing signatures (#2260) 2025-12-02 11:29:48 +11:00
David Nguyen bc19699a58 feat: add dutch language (#2255) 2025-12-02 11:28:04 +11:00
Harishraju04 55480826de docs: add missing translate:compile step to setup guid 2025-12-01 12:05:51 +11:00
Konrad 327b0eaf86 fix(i18n): add pluralization to pagination (#2217) 2025-12-01 11:38:57 +11:00
Konrad 2de5c1992f chore(i18n): add message context to subscription status (#2220) 2025-12-01 11:34:43 +11:00
Konrad df0c03816e chore(i18n): add message context for "Free" and "Paid" (#2222) 2025-12-01 11:30:07 +11:00
Konrad a610a06372 fix(i18n): mark table headers for translation (#2174) 2025-12-01 11:20:18 +11:00
Konrad d5e085d7ee fix(i18n): mark document visibility strings for translation (#2263) 2025-12-01 11:02:55 +11:00
Timur Ercan c322356654 chore: remove cummulative mau (#2250) 2025-11-28 18:07:30 +11:00
Lucas Smith b16862b480 chore: update embed authoring docs (#2254) 2025-11-27 23:29:06 +11:00
Lucas Smith 7065b0dd88 chore: add translations (#2253)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-11-27 21:36:48 +11:00
Lucas Smith dff9cfec05 chore: add translations (#2228) 2025-11-27 16:40:18 +11:00
David Nguyen d84cf0e58d chore: extract translations (#2252) 2025-11-27 16:04:22 +11:00
Ephraim Duncan 5d8b147199 fix: delay field tooltip scroll on envelope item switch (#2246) 2025-11-27 13:37:33 +11:00
Filbert Wijaya 7d28295d42 build: remove unsupported auto-install-peers from .npmrc (#2199) 2025-11-27 13:35:23 +11:00
Ephraim Duncan 94646cd48a perf: add database indexes for insights queries (#2211) 2025-11-26 21:21:01 +11:00
Ephraim Duncan 14db9b8203 feat: add navigation links between admin org pages (#2243) 2025-11-26 15:15:29 +11:00
Lucas Smith 6ae672c16b v2.1.0 2025-11-25 16:38:06 +11:00
Lucas Smith e9a9d65937 chore: telemetry (#2241) 2025-11-25 16:35:26 +11:00
David Nguyen d857dfdb38 feat: add webhook logs (#2237) 2025-11-25 16:03:52 +11:00
Lucas Smith 11a56f3228 chore: telemetry (#2240) 2025-11-25 16:01:31 +11:00
Lucas Smith 91642ddf0b fix: add missing properties for template/use (#2234)
Adds the `override` and `attachments` properties to the
`api/v2/templates/use` endpoint that were previously missing.
2025-11-25 11:44:47 +11:00
David Nguyen e364b08b6a fix: optimize webhook routing (#2236) 2025-11-25 11:43:23 +11:00
Catalin Pit 5df3932958 fix: update branding logic (#2238)
Update branding logic to ensure company details are displayed only when
branding is enabled
2025-11-24 21:45:31 +11:00
Lucas Smith ae31860b16 fix: USE_INTERNAL_URL_BROWSERLESS breaks builds (#2233) 2025-11-23 23:49:08 +11:00
Timur Ercan 16ee6b7a6d fix: give the possibility to use internal webapp url in browserless requests (get-certificate-pdf and get-audit-logs-pdf) (#2127) (#2230) 2025-11-22 20:49:34 +11:00
Matteo Sillitti 921c3d1ff3 fix: give the possibility to use internal webapp url in browserless requests (get-certificate-pdf and get-audit-logs-pdf) (#2127) 2025-11-22 20:36:24 +11:00
Ephraim Duncan 2d7a4d0dde docs: add missing environment variables to self-hosting guide (#2225) 2025-11-22 20:28:51 +11:00
Lucas Smith d2176627ca chore: dependency updates (#2229) 2025-11-22 20:28:20 +11:00
Lucas Smith 17c6098638 v2.0.14 2025-11-20 15:12:40 +11:00
Lucas Smith e5bde53ee4 chore: add translations (#2223) 2025-11-20 15:09:13 +11:00
Lucas Smith 0663605ffd fix: handle loading files in embedded authoring update flows (#2218) 2025-11-20 15:07:41 +11:00
Lucas Smith 1bbe561162 chore: add pending ui to signing completion page (#2224)
Adds a pending UI state to the signing completion page for when all
recipients have finished signing but the document hasn't completed the
sealing background job.

<img width="695" height="562" alt="image"
src="https://github.com/user-attachments/assets/b015bc38-9489-4baa-ac0a-07cb1ac24b25"
/>
2025-11-20 15:07:26 +11:00
Dailson Allves fbc156722a feat: add Portuguese (Brazil) translation support version 2.0.6 (#2165)
Portuguese (Brazil) Translation Support for Documenso
2025-11-20 14:14:47 +11:00
Karlo f5d63fb76c feat: add option to change or disable OIDC login prompt parameter (#2037) 2025-11-20 13:08:36 +11:00
Catalin Pit 374477e692 refactor: improve layout of completed signing page (#2209) 2025-11-20 11:04:41 +11:00
David Nguyen 11d9bde8f8 fix: improve sealing speed (#2210) 2025-11-19 14:15:12 +11:00
Lucas Smith fa1680aaf1 v2.0.13 2025-11-18 16:59:02 +11:00
David Nguyen 798b6bd750 feat: add japanese chinese and korean support (#2202)
## Description

Adds the following languages since we updated our PDF sealing to support
special characters
- Japanese
- Korean
- Chinese (Simplified)

## Tests

Ran through the signing process in these new languages.
2025-11-18 16:57:38 +11:00
Lucas Smith 8fbace0f61 fix: viewed webhook had stale data (#2208) 2025-11-18 16:57:14 +11:00
David Nguyen 1bbd04be9b feat: add field dev mode (#2203) 2025-11-18 16:57:06 +11:00
Lucas Smith 6aa56fe7e0 chore: add translations (#2182) 2025-11-17 15:48:02 +11:00
342 changed files with 82322 additions and 88843 deletions
+21
View File
@@ -23,6 +23,10 @@ NEXT_PRIVATE_OIDC_CLIENT_ID=""
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
# Specifies the prompt to use for OIDC signin, explicitly setting
# an empty string will omit the prompt parameter.
# See: https://www.cerberauth.com/blog/openid-connect-oauth2-prompts/
NEXT_PRIVATE_OIDC_PROMPT="login"
# [[URLS]]
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
@@ -134,6 +138,23 @@ NEXT_PUBLIC_POSTHOG_KEY=""
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
# [[TELEMETRY]]
# OPTIONAL: Set to "true" to disable anonymous telemetry for self-hosted instances.
# Telemetry helps us understand how Documenso is being used and improve the product.
# We only collect: app version, installation ID, and node ID. No personal data is collected.
DOCUMENSO_DISABLE_TELEMETRY=
# [[AI]]
# OPTIONAL: Google Cloud Project ID for Vertex AI.
GOOGLE_VERTEX_PROJECT_ID=""
# OPTIONAL: Google Cloud region for Vertex AI. Defaults to "global".
GOOGLE_VERTEX_LOCATION="global"
# OPTIONAL: API key for Google Vertex AI (Gemini). Get your key from:
# https://console.cloud.google.com/vertex-ai/studio/settings/api-keys
GOOGLE_VERTEX_API_KEY=""
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
+8 -6
View File
@@ -1,14 +1,19 @@
name: Playwright Tests
on:
push:
branches: ['main', 'feat/rr7']
branches: ['main']
pull_request:
branches: ['main']
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e_tests:
name: 'E2E Tests'
timeout-minutes: 60
runs-on: warp-ubuntu-2204-x64-16x
runs-on: warp-ubuntu-2204-x64-8x
steps:
- uses: actions/checkout@v4
@@ -28,9 +33,6 @@ jobs:
- name: Seed the database
run: npm run prisma:seed
- name: Build app
run: npm run build
- name: Install playwright browsers
run: npx playwright install --with-deps
@@ -45,7 +47,7 @@ jobs:
with:
name: test-results
path: 'packages/app-tests/**/test-results/*'
retention-days: 30
retention-days: 7
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
+4
View File
@@ -36,6 +36,8 @@ jobs:
- name: Build the docker image
env:
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
NEXT_PRIVATE_TELEMETRY_KEY: ${{ secrets.NEXT_PRIVATE_TELEMETRY_KEY }}
NEXT_PRIVATE_TELEMETRY_HOST: ${{ secrets.NEXT_PRIVATE_TELEMETRY_HOST }}
run: |
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)"
@@ -43,6 +45,8 @@ jobs:
docker build \
-f ./docker/Dockerfile \
--progress=plain \
--build-arg NEXT_PRIVATE_TELEMETRY_KEY="${NEXT_PRIVATE_TELEMETRY_KEY:-}" \
--build-arg NEXT_PRIVATE_TELEMETRY_HOST="${NEXT_PRIVATE_TELEMETRY_HOST:-}" \
-t "documenso/documenso-$BUILD_PLATFORM:latest" \
-t "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA" \
-t "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION" \
+45 -2
View File
@@ -17,6 +17,7 @@ jobs:
environment: Translations
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -26,12 +27,54 @@ jobs:
- name: Extract translations
run: npm run translate:extract
- name: Check and commit any files created
- name: Commit changes and push to reserved branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
BRANCH="chore/extract-translations"
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@documenso.com'
git fetch origin
# Create branch locally (always reset to main)
git checkout -B "$BRANCH" origin/main
# Stage translation output
git add packages/lib/translations
git diff --staged --quiet --exit-code || (git commit -m "chore: extract translations" && git push)
# If no changes, exit early
if git diff --staged --quiet; then
echo "No translation changes found."
exit 0
fi
# Commit fresh snapshot
git commit -m "chore: extract translations"
# Force push reserved branch
git push origin "$BRANCH" --force
# Does a PR already exist?
EXISTING_PR=$(gh pr list \
--state open \
--head "$BRANCH" \
--json number \
--jq '.[0].number // empty')
if [ -z "$EXISTING_PR" ]; then
echo "No existing PR — creating new one."
gh pr create \
--title "chore: extract translations" \
--body "Automated translation extraction" \
--base main \
--head "$BRANCH"
else
echo "PR #$EXISTING_PR already exists — not creating a new one."
fi
- name: Compile translations
id: compile_translations
+3
View File
@@ -60,3 +60,6 @@ CLAUDE.md
# agents
.specs
# scripts
scripts/output*
-1
View File
@@ -1,3 +1,2 @@
auto-install-peers = true
legacy-peer-deps = true
prefer-dedupe = true
+4 -5
View File
@@ -1,6 +1,3 @@
> 🚨 🚨 🚨
> Documenso 2.0.0 is live on Product Hunt 🎉 <a href="https://documen.so/launch" target="_blank" rel="noopener noreferrer" style="text-decoration: underline;">Join us to celebrate the best Documenso yet 🪩</a>
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
<p align="center" style="margin-top: 20px">
@@ -174,9 +171,11 @@ git clone https://github.com/<your-username>/documenso
5. Create the database schema by running `npm run prisma:migrate-dev`
6. Run `npm run dev` in the root directory to start
6. Run `npm run translate:compile` in the root directory to compile lingui
7. Register a new user at http://localhost:3000/signup
7. Run `npm run dev` in the root directory to start
8. Register a new user at http://localhost:3000/signup
---
@@ -1,7 +1,8 @@
import type { NextConfig } from 'next';
import nextra from 'nextra';
/** @type {import('next').NextConfig} */
const nextConfig = {
const nextConfig: NextConfig = {
transpilePackages: [
'@documenso/assets',
'@documenso/lib',
+8 -8
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev -p 3002",
"build": "next build && next-sitemap",
"build": "next build",
"start": "next start -p 3002",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules"
@@ -15,18 +15,18 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "14.2.28",
"next-plausible": "^3.12.0",
"nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4",
"next": "^15.5.7",
"next-plausible": "^3.12.5",
"nextra": "^3",
"nextra-theme-docs": "^3",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react": "18.3.27",
"@types/react-dom": "^18",
"next-sitemap": "^4.2.3",
"pagefind": "^1.2.0",
"typescript": "5.6.2"
}
}
}
-10
View File
@@ -1,10 +0,0 @@
import { PlausibleProvider } from '../providers/plausible.tsx';
import '../styles.css';
export default function App({ Component, pageProps }) {
return (
<PlausibleProvider>
<Component {...pageProps} />
</PlausibleProvider>
);
}
+18
View File
@@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react';
import { PlausibleProvider } from '../providers/plausible';
import '../styles.css';
export type AppProps = {
Component: React.ComponentType<any>;
pageProps: any;
};
export default function App({ Component, pageProps }: AppProps) {
return (
<PlausibleProvider>
<Component {...pageProps} />
</PlausibleProvider>
);
}
+34
View File
@@ -0,0 +1,34 @@
export default {
index: {
type: 'page',
title: 'Home',
display: 'hidden',
theme: {
timestamp: false,
},
},
users: {
type: 'page',
title: 'Users',
},
developers: {
type: 'page',
title: 'Developers',
},
updates: {
title: "What's New",
type: 'menu',
items: {
changelog: {
title: 'Changelog',
href: 'https://documenso.com/changelog',
newWindow: true,
},
blog: {
title: 'Blog',
href: 'https://documenso.com/blog',
newWindow: true,
},
},
},
};
-34
View File
@@ -1,34 +0,0 @@
{
"index": {
"type": "page",
"title": "Home",
"display": "hidden",
"theme": {
"timestamp": false
}
},
"users": {
"type": "page",
"title": "Users"
},
"developers": {
"type": "page",
"title": "Developers"
},
"updates": {
"title": "What's New",
"type": "menu",
"items": {
"changelog": {
"title": "Changelog",
"href": "https://documenso.com/changelog",
"newWindow": true
},
"blog": {
"title": "Blog",
"href": "https://documenso.com/blog",
"newWindow": true
}
}
}
}
@@ -0,0 +1,18 @@
export default {
index: 'Introduction',
'-- Development & Deployment': {
type: 'separator',
title: 'Development & Deployment',
},
'local-development': 'Local Development',
'developer-mode': 'Developer Mode',
'self-hosting': 'Self Hosting',
contributing: 'Contributing',
'-- API & Integration Guides': {
type: 'separator',
title: 'API & Integration Guides',
},
'public-api': 'Public API',
embedding: 'Embedding',
webhooks: 'Webhooks',
};
@@ -1,18 +0,0 @@
{
"index": "Introduction",
"-- Development & Deployment": {
"type": "separator",
"title": "Development & Deployment"
},
"local-development": "Local Development",
"developer-mode": "Developer Mode",
"self-hosting": "Self Hosting",
"contributing": "Contributing",
"-- API & Integration Guides": {
"type": "separator",
"title": "API & Integration Guides"
},
"public-api": "Public API",
"embedding": "Embedding",
"webhooks": "Webhooks"
}
@@ -0,0 +1,4 @@
export default {
index: 'Getting Started',
'contributing-translations': 'Contributing Translations',
};
@@ -1,4 +0,0 @@
{
"index": "Getting Started",
"contributing-translations": "Contributing Translations"
}
@@ -0,0 +1,11 @@
export default {
index: 'Get Started',
react: 'React Integration',
vue: 'Vue Integration',
svelte: 'Svelte Integration',
solid: 'Solid Integration',
preact: 'Preact Integration',
angular: 'Angular Integration',
'css-variables': 'CSS Variables',
authoring: 'Authoring',
};
@@ -1,11 +0,0 @@
{
"index": "Get Started",
"react": "React Integration",
"vue": "Vue Integration",
"svelte": "Svelte Integration",
"solid": "Solid Integration",
"preact": "Preact Integration",
"angular": "Angular Integration",
"css-variables": "CSS Variables",
"authoring": "Authoring"
}
@@ -5,18 +5,38 @@ description: Learn how to use embedded authoring to create documents and templat
# Embedded Authoring
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation directly within your application.
In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation and editing directly within your application.
## How Embedded Authoring Works
The embedded authoring feature enables your users to create new documents without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
The embedded authoring feature enables your users to create and edit documents and templates without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions.
## Creating Documents with Embedded Authoring
## Available Components
To implement document creation in your application, use the `EmbedCreateDocument` component from our SDK:
The SDK provides four authoring components:
- **`EmbedCreateDocumentV1`** - Create new documents
- **`EmbedCreateTemplateV1`** - Create new templates
- **`EmbedUpdateDocumentV1`** - Edit existing documents
- **`EmbedUpdateTemplateV1`** - Edit existing templates
React Example:
```jsx
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
import {
EmbedCreateDocumentV1,
EmbedCreateTemplateV1,
EmbedUpdateDocumentV1,
EmbedUpdateTemplateV1,
} from '@documenso/embed-react';
```
## Creating Documents
To implement document creation in your application, use the `EmbedCreateDocumentV1` component:
```jsx
import { EmbedCreateDocumentV1 } from '@documenso/embed-react';
const DocumentCreator = () => {
// You'll need to obtain a presign token using your API key
@@ -37,9 +57,88 @@ const DocumentCreator = () => {
};
```
## Creating Templates
To create templates, use the `EmbedCreateTemplateV1` component:
```jsx
import { EmbedCreateTemplateV1 } from '@documenso/embed-react';
const TemplateCreator = () => {
const presignToken = 'YOUR_PRESIGN_TOKEN';
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedCreateTemplate
presignToken={presignToken}
externalId="template-12345"
onTemplateCreated={(data) => {
console.log('Template created with ID:', data.templateId);
console.log('External reference ID:', data.externalId);
}}
/>
</div>
);
};
```
## Updating Documents
To edit existing documents, use the `EmbedUpdateDocumentV1` component:
```jsx
import { EmbedUpdateDocumentV1 } from '@documenso/embed-react';
const DocumentEditor = () => {
const presignToken = 'YOUR_PRESIGN_TOKEN';
const documentId = 123; // The ID of the document to edit
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedUpdateDocument
presignToken={presignToken}
documentId={documentId}
externalId="order-12345"
onlyEditFields={false}
onDocumentUpdated={(data) => {
console.log('Document updated:', data.documentId);
}}
/>
</div>
);
};
```
## Updating Templates
To edit existing templates, use the `EmbedUpdateTemplateV1` component:
```jsx
import { EmbedUpdateTemplateV1 } from '@documenso/embed-react';
const TemplateEditor = () => {
const presignToken = 'YOUR_PRESIGN_TOKEN';
const templateId = 456; // The ID of the template to edit
return (
<div style={{ height: '800px', width: '100%' }}>
<EmbedUpdateTemplate
presignToken={presignToken}
templateId={templateId}
externalId="template-12345"
onlyEditFields={false}
onTemplateUpdated={(data) => {
console.log('Template updated:', data.templateId);
}}
/>
</div>
);
};
```
## Obtaining a Presign Token
Before using the `EmbedCreateDocument` component, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
Before using any of the authoring components, you'll need to obtain a presign token from your backend. This token authorizes the embedding session.
You can create a presign token by making a request to:
@@ -53,17 +152,29 @@ You can find more details on this request at our [API Documentation](https://ope
## Configuration Options
The `EmbedCreateDocument` component accepts several configuration options:
All authoring components accept the following configuration options:
| Option | Type | Description |
| ------------------ | ------- | ------------------------------------------------------------------ |
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
| `externalId` | string | Optional reference ID from your system to link with the document. |
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
| `css` | string | Optional custom CSS to style the embedded component. |
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
| `className` | string | Optional CSS class name for the iframe. |
| Option | Type | Description |
| ------------------ | ------- | -------------------------------------------------------------------------- |
| `presignToken` | string | **Required**. The authentication token for the embedding session. |
| `externalId` | string | Optional reference ID from your system to link with the document/template. |
| `host` | string | Optional custom host URL. Defaults to `https://app.documenso.com`. |
| `css` | string | Optional custom CSS to style the embedded component. |
| `cssVars` | object | Optional CSS variables for colors, spacing, and more. |
| `darkModeDisabled` | boolean | Optional flag to disable dark mode. |
| `className` | string | Optional CSS class name for the iframe. |
| `additionalProps` | object | Optional additional props to pass to the iframe (for testing features). |
| `features` | object | Optional feature toggles to customize the authoring experience. |
### Update Component Specific Props
The `EmbedUpdateDocument` and `EmbedUpdateTemplate` components also accept:
| Option | Type | Description |
| ---------------- | ------- | -------------------------------------------------------------------------------------------------------------- |
| `documentId` | number | **Required for EmbedUpdateDocument**. The ID of the document to edit. |
| `templateId` | number | **Required for EmbedUpdateTemplate**. The ID of the template to edit. |
| `onlyEditFields` | boolean | Optional flag to restrict editing to fields only skipping the recipient configuration step (default: `false`). |
## Feature Toggles
@@ -83,9 +194,11 @@ You can customize the authoring experience by enabling or disabling specific fea
/>
```
## Handling Document Creation Events
## Handling Events
The `onDocumentCreated` callback is triggered when a document is successfully created, providing both the document ID and your external reference ID:
Each component provides callbacks for handling completion events:
### Document Events
```jsx
<EmbedCreateDocument
@@ -99,11 +212,47 @@ The `onDocumentCreated` callback is triggered when a document is successfully cr
updateOrderDocument(data.externalId, data.documentId);
}}
/>
<EmbedUpdateDocument
presignToken="YOUR_PRESIGN_TOKEN"
documentId={123}
onDocumentUpdated={(data) => {
console.log('Document updated:', data.documentId);
// Handle document update
}}
/>
```
### Template Events
```jsx
<EmbedCreateTemplate
presignToken="YOUR_PRESIGN_TOKEN"
externalId="template-12345"
onTemplateCreated={(data) => {
console.log('Template created:', data.templateId);
// Handle template creation
}}
/>
<EmbedUpdateTemplate
presignToken="YOUR_PRESIGN_TOKEN"
templateId={456}
onTemplateUpdated={(data) => {
console.log('Template updated:', data.templateId);
// Handle template update
}}
/>
```
All event callbacks receive an object with:
- `documentId` or `templateId` - The ID of the created/updated document or template
- `externalId` - Your external reference ID (if provided)
## Styling the Embedded Component
You can customize the appearance of the embedded component using standard CSS classes:
You can customize the appearance of the embedded component using standard CSS classes, custom CSS, and CSS variables:
```jsx
<EmbedCreateDocument
@@ -130,20 +279,48 @@ Here's a complete example of integrating document creation in a React applicatio
```tsx
import { useState } from 'react';
import { unstable_EmbedCreateDocument as EmbedCreateDocument } from '@documenso/embed-react';
import { EmbedCreateDocumentV1, EmbedUpdateDocumentV1 } from '@documenso/embed-react';
function DocumentCreator() {
function DocumentManager() {
// In a real application, you would fetch this token from your backend
// using your API key at /api/v2/embedding/create-presign-token
const presignToken = 'YOUR_PRESIGN_TOKEN';
const [documentId, setDocumentId] = useState<number | null>(null);
const [mode, setMode] = useState<'create' | 'edit'>('create');
if (documentId) {
if (documentId && mode === 'create') {
return (
<div>
<h2>Document Created Successfully!</h2>
<p>Document ID: {documentId}</p>
<button onClick={() => setDocumentId(null)}>Create Another Document</button>
<div>
<button onClick={() => setMode('edit')}>Edit Document</button>
<button
onClick={() => {
setDocumentId(null);
setMode('create');
}}
>
Create Another Document
</button>
</div>
</div>
);
}
if (mode === 'edit' && documentId) {
return (
<div style={{ height: '800px', width: '100%' }}>
<button onClick={() => setMode('create')}>Back to Create</button>
<EmbedUpdateDocument
presignToken={presignToken}
documentId={documentId}
externalId="order-12345"
onDocumentUpdated={(data) => {
console.log('Document updated:', data.documentId);
setMode('create');
}}
/>
</div>
);
}
@@ -153,6 +330,14 @@ function DocumentCreator() {
<EmbedCreateDocument
presignToken={presignToken}
externalId="order-12345"
features={{
allowConfigureSignatureTypes: true,
allowConfigureLanguage: true,
allowConfigureDateFormat: true,
allowConfigureTimezone: true,
allowConfigureRedirectUrl: true,
allowConfigureCommunication: true,
}}
onDocumentCreated={(data) => {
setDocumentId(data.documentId);
}}
@@ -161,7 +346,38 @@ function DocumentCreator() {
);
}
export default DocumentCreator;
export default DocumentManager;
```
With embedded authoring, your users can seamlessly create documents within your application, enhancing the overall user experience and streamlining document workflows.
## Advanced Usage
### Using Additional Props
You can pass additional props to the iframe for testing features before they're officially supported:
```jsx
<EmbedCreateDocument
presignToken="YOUR_PRESIGN_TOKEN"
additionalProps={{
experimentalFeature: true,
customSetting: 'value',
}}
/>
```
### Restricting To Only Field Editing
When updating documents or templates, you can restrict editing to fields only skipping the recipient configuration step:
```jsx
<EmbedUpdateDocument
presignToken="YOUR_PRESIGN_TOKEN"
documentId={123}
onlyEditFields={true}
onDocumentUpdated={(data) => {
console.log('Fields updated:', data.documentId);
}}
/>
```
With embedded authoring, your users can seamlessly create and edit documents and templates within your application, enhancing the overall user experience and streamlining document workflows.
@@ -3,16 +3,16 @@ title: Developer Documentation
description: Learn how to run Documenso locally, use our API, integrate webhooks, contribute to the project, and self-host Documenso.
---
import { Card, Cards } from 'nextra/components';
import { Cards } from 'nextra/components';
# Developer Documentation
The developer documentation is a comprehensive guide to help you:
<Cards>
<Card title="Set up dev environment" href="/developers/local-development" />
<Card title="Use the API" href="/developers/public-api" />
<Card title="Integrate webhooks" href="/developers/webhooks" />
<Card title="Contribute to the project" href="/developers/contributing" />
<Card title="Self-host Documenso" href="/developers/self-hosting" />
<Cards.Card title="Set up dev environment" href="/developers/local-development" />
<Cards.Card title="Use the API" href="/developers/public-api" />
<Cards.Card title="Integrate webhooks" href="/developers/webhooks" />
<Cards.Card title="Contribute to the project" href="/developers/contributing" />
<Cards.Card title="Self-host Documenso" href="/developers/self-hosting" />
</Cards>
@@ -0,0 +1,8 @@
export default {
index: 'Get Started',
quickstart: 'Developer Quickstart',
manual: 'Manual Setup',
gitpod: 'Gitpod',
'signing-certificate': 'Signing Certificate',
translations: 'Translations',
};
@@ -1,8 +0,0 @@
{
"index": "Get Started",
"quickstart": "Developer Quickstart",
"manual": "Manual Setup",
"gitpod": "Gitpod",
"signing-certificate": "Signing Certificate",
"translations": "Translations"
}
@@ -0,0 +1,6 @@
export default {
index: 'Get Started',
authentication: 'Authentication',
'rate-limits': 'Rate Limits',
versioning: 'Versioning',
};
@@ -1,6 +0,0 @@
{
"index": "Get Started",
"authentication": "Authentication",
"rate-limits": "Rate Limits",
"versioning": "Versioning"
}
@@ -0,0 +1,8 @@
export default {
index: 'Getting Started',
'signing-certificate': 'Signing Certificate',
'how-to': 'How To',
'setting-up-oauth-providers': 'Setting up OAuth Providers',
telemetry: 'Telemetry',
'ai-features': 'AI Recipient & Field Detection',
};
@@ -1,6 +0,0 @@
{
"index": "Getting Started",
"signing-certificate": "Signing Certificate",
"how-to": "How To",
"setting-up-oauth-providers": "Setting up OAuth Providers"
}
@@ -0,0 +1,72 @@
---
title: AI Recipient & Field Detection (Self-hosting)
description: Configure Google Vertex AI so Documenso can detect recipients and fields automatically.
---
import { Callout, Steps } from 'nextra/components';
# AI Recipient & Field Detection (Self-hosting)
This guide covers how to enable the AI recipient and field detection features when you self-host Documenso.
## What this enables
- Detect recipients from uploaded PDFs (roles, names, emails when present).
- Detect and place fields (signature, initials, name, email, date, text, number, radio, checkbox) onto draft envelopes.
- Built-in rate limits (3 requests per minute per IP) to prevent abuse.
## Prerequisites
- A Google Cloud project with the **Vertex AI API** enabled and billing active.
- A **Vertex AI Express API key** with access to Gemini models (create via the [Vertex AI Express flow](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview) and manage keys in [API keys](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys)).
- Documenso version that includes the AI detection feature and the corresponding database migration.
## Configure environment variables
Add these variables to your deployment `.env` (or secret manager):
```
GOOGLE_VERTEX_PROJECT_ID="<your-gcp-project-id>"
GOOGLE_VERTEX_API_KEY="<your-vertex-api-key>"
# Optional, defaults to "global"
GOOGLE_VERTEX_LOCATION="global"
```
<Callout type="info">
Use a region close to your users if you need data residency considerations (e.g. `europe-west1`).
If you omit the location, Documenso uses `global`. Not all models are available in every region;
if a model is unavailable, switch to a supported region.
</Callout>
## Deploy with the published container
- Use the official Documenso image (DockerHub or GHCR) and supply the Vertex env vars above.
- Ensure migrations run on startup (the container runs `prisma migrate deploy` in production mode).
- Restart the container after adding or changing Vertex env vars.
## Enable the feature in Documenso
Once the service is running with the Vertex env vars:
<Steps>
### Organisation settings
Go to **Settings → Document Preferences → AI Features** and set to **Enabled**. Teams that inherit organisation defaults will pick this up.
### Team settings
If a team overrides organisation defaults, go to **Team Settings → Document Preferences → AI Features** and choose **Enabled** (or **Inherit** to follow the organisation).
### Verify in the editor
Open a draft envelope. In **Recipients**, you should see the sparkle button for AI detection. In **Fields**, you should see **Detect with AI** available.
</Steps>
## Troubleshooting
- **Too many requests**: Wait a minute or two and retry (rate limit is 3/min per IP).
- **AI options hidden**: Ensure the env vars are set, the server was restarted after setting them, and `aiFeaturesEnabled` is enabled at organisation/team level.
- **Detection fails immediately**: Confirm the Vertex API key is valid and the project has Vertex AI enabled. Check server logs for status codes from Vertex.
If issues persist, recheck env vars, restart the service, and confirm the Prisma migration was applied.
@@ -119,6 +119,8 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
```
For full AI setup details (including model availability notes), see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
### Set Up Your Signing Certificate
<Callout type="warning">
@@ -267,47 +269,92 @@ You can access the Documenso application by visiting the URL you provided for th
The environment variables listed above are a subset of those available for configuring Documenso. The table below provides a complete list of environment variables and their descriptions.
| Variable | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `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_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. |
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. |
## Telemetry
Documenso collects anonymous telemetry data to help us understand how the software is being used and improve the product. This telemetry is **enabled by default** for self-hosted instances.
### What We Collect
We collect minimal, privacy-preserving data:
- **App Version**: The version of Documenso you are running
- **Installation ID**: A unique identifier for your installation (stored in your database)
- **Node ID**: A unique identifier for each server/container instance (stored in the OS temp directory)
We do **not** collect any personal data, document contents, user information, or usage patterns.
### Events
- **Server Startup**: Captured once when the server starts
- **Server Heartbeat**: Captured every hour while the server is running
### Disabling Telemetry
To disable telemetry, set the following environment variable:
```bash
DOCUMENSO_DISABLE_TELEMETRY=true
```
This will completely disable all telemetry data collection.
## Run as a Service
@@ -0,0 +1,85 @@
# Telemetry
Documenso collects anonymous telemetry data from self-hosted instances to help us understand how the software is being used and make improvements to the product. This telemetry is enabled by default, but you can easily disable it if you prefer.
## What We Collect
We collect minimal, privacy-preserving information that helps us understand the health and adoption of self-hosted installations:
- **App Version**: The version of Documenso you are running. This helps us understand which versions are in use and prioritize support for older versions.
- **Installation ID**: A unique identifier for your installation. This is stored in your database and helps us count distinct installations without knowing who you are.
- **Node ID**: A unique identifier for each server or container instance. This is stored in your operating system's temporary directory and helps us understand deployment patterns (for example, how many instances are running in a cluster).
### What We Don't Collect
We do **not** collect any of the following:
- Personal information about you or your users
- Document contents or file names
- User email addresses or names
- Usage patterns or feature usage statistics
- Server logs or error messages
- Any data that could identify your organization or users
## Why We Collect Telemetry
The telemetry data we collect serves several important purposes:
1. **Product Improvement**: Understanding which versions are in use helps us prioritize bug fixes and security updates for the versions that matter most.
2. **Support Planning**: Knowing how many installations exist and their deployment patterns helps us plan support resources and documentation.
3. **Feature Development**: Understanding deployment patterns (like cluster sizes) helps us make better architectural decisions for future features.
4. **Community Health**: Tracking adoption helps us understand the growth of the self-hosted community and allocate resources accordingly.
All of this is done anonymously and in aggregate. We cannot identify you, your organization, or your users from the telemetry data we collect.
## Events We Track
We track two simple events:
- **Server Startup**: Captured once when your server starts. This tells us when installations are first set up or restarted.
- **Server Heartbeat**: Captured every hour while your server is running. This helps us understand how many active installations exist and their uptime patterns.
## How to Disable Telemetry
If you prefer not to send telemetry data, you can disable it by setting an environment variable.
### Using Environment Variables
Add the following to your environment configuration:
```bash
DOCUMENSO_DISABLE_TELEMETRY=true
```
### Docker
If you're using Docker, you can set this in your `docker-compose.yml`:
```yaml
services:
app:
environment:
- DOCUMENSO_DISABLE_TELEMETRY=true
```
Or pass it when running a container:
```bash
docker run -e DOCUMENSO_DISABLE_TELEMETRY=true ...
```
### After Disabling
Once you set `DOCUMENSO_DISABLE_TELEMETRY=true` and restart your server, no telemetry data will be sent. The telemetry client will not initialize, and no network requests will be made to our telemetry servers.
Note: If you previously had telemetry enabled, the installation ID stored in your database will remain, but it will no longer be used or sent anywhere.
## Questions or Concerns
If you have questions about our telemetry practices or concerns about privacy, please reach out to us. We're committed to transparency and respect your choice to disable telemetry if you prefer.
+23
View File
@@ -0,0 +1,23 @@
export default {
index: 'Introduction',
support: 'Support',
'-- How To Use': {
type: 'separator',
title: 'How To Use',
},
'get-started': 'Get Started',
profile: 'Public Profile',
organisations: 'Organisations',
documents: 'Documents',
templates: 'Templates',
branding: 'Branding',
'email-domains': 'Email Domains',
'direct-links': 'Direct Signing Links',
'-- Legal Overview': {
type: 'separator',
title: 'Legal Overview',
},
'fair-use': 'Fair Use Policy',
licenses: 'Licenses',
compliance: 'Compliance',
};
-23
View File
@@ -1,23 +0,0 @@
{
"index": "Introduction",
"support": "Support",
"-- How To Use": {
"type": "separator",
"title": "How To Use"
},
"get-started": "Get Started",
"profile": "Public Profile",
"organisations": "Organisations",
"documents": "Documents",
"templates": "Templates",
"branding": "Branding",
"email-domains": "Email Domains",
"direct-links": "Direct Signing Links",
"-- Legal Overview": {
"type": "separator",
"title": "Legal Overview"
},
"fair-use": "Fair Use Policy",
"licenses": "Licenses",
"compliance": "Compliance"
}
@@ -0,0 +1,4 @@
export default {
'signature-levels': 'Signature Levels',
'standards-and-regulations': 'Standards and Regulations',
};
@@ -1,4 +0,0 @@
{
"signature-levels": "Signature Levels",
"standards-and-regulations": "Standards and Regulations"
}
@@ -0,0 +1,8 @@
export default {
'sending-documents': 'Sending Documents',
'document-preferences': 'Document Preferences',
'document-visibility': 'Document Visibility',
fields: 'Document Fields',
'email-preferences': 'Email Preferences',
'ai-detection': 'AI Recipient & Field Detection',
};
@@ -1,7 +0,0 @@
{
"sending-documents": "Sending Documents",
"document-preferences": "Document Preferences",
"document-visibility": "Document Visibility",
"fields": "Document Fields",
"email-preferences": "Email Preferences"
}
@@ -0,0 +1,68 @@
---
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.
@@ -0,0 +1,5 @@
export default {
index: 'Overview',
'community-edition': 'Community Edition',
'enterprise-edition': 'Enterprise Edition',
};
@@ -1,5 +0,0 @@
{
"index": "Overview",
"community-edition": "Community Edition",
"enterprise-edition": "Enterprise Edition"
}
@@ -0,0 +1,8 @@
export default {
index: 'Introduction',
members: 'Members',
groups: 'Groups',
teams: 'Teams',
sso: 'SSO',
billing: 'Billing',
};
@@ -1,8 +0,0 @@
{
"index": "Introduction",
"members": "Members",
"groups": "Groups",
"teams": "Teams",
"sso": "SSO",
"billing": "Billing"
}
@@ -0,0 +1,4 @@
export default {
index: 'Configuration',
'microsoft-entra-id': 'Microsoft Entra ID',
};
@@ -1,4 +0,0 @@
{
"index": "Configuration",
"microsoft-entra-id": "Microsoft Entra ID"
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

File diff suppressed because one or more lines are too long
+4
View File
@@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
scroll-behavior: smooth;
}
@@ -8,7 +8,9 @@ module.exports = {
...baseConfig.content,
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./content/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
fontFamily: {
+6 -8
View File
@@ -4,7 +4,7 @@ import { useConfig } from 'nextra-theme-docs';
const themeConfig: DocsThemeConfig = {
logo: <span>Documenso</span>,
head: function useHead() {
const config = useConfig<{ title?: string; description?: string }>();
const config = useConfig();
const title = `${config.frontMatter.title} | Documenso Docs` || 'Documenso Docs';
const description = config.frontMatter.description || 'The official Documenso documentation';
@@ -12,6 +12,7 @@ const themeConfig: DocsThemeConfig = {
return (
<>
<meta httpEquiv="Content-Language" content="en" />
<title>{title}</title>
<meta name="title" content={title} />
<meta name="og:title" content={title} />
<meta name="description" content={description} />
@@ -46,7 +47,7 @@ const themeConfig: DocsThemeConfig = {
},
docsRepositoryBase: 'https://github.com/documenso/documenso/tree/main/apps/documentation',
footer: {
text: (
content: (
<span>
{new Date().getFullYear()} ©{' '}
<a href="https://documen.so" target="_blank">
@@ -56,12 +57,9 @@ const themeConfig: DocsThemeConfig = {
</span>
),
},
primaryHue: 100,
primarySaturation: 48.47,
useNextSeoProps() {
return {
titleTemplate: '%s | Documenso Docs',
};
color: {
hue: 100,
saturation: 48.47,
},
};
+21 -6
View File
@@ -1,13 +1,17 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
@@ -18,10 +22,21 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
},
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "tailwind.config.js"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"tailwind.config.js"
],
"exclude": [
"node_modules"
]
}
-4
View File
@@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {};
export default nextConfig;
+3 -3
View File
@@ -11,12 +11,12 @@
},
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.5.0",
"next": "14.2.28"
"luxon": "^3.7.2",
"next": "^15.5.7"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.3.5",
"@types/react": "18.3.27",
"typescript": "5.6.2"
}
}
@@ -45,7 +45,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
toast({
title: _(msg`Document deleted`),
description: 'The Document has been deleted successfully.',
description: _(msg`The Document has been deleted successfully.`),
duration: 5000,
});
@@ -54,8 +54,9 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description:
'We encountered an unknown error while attempting to delete your document. Please try again later.',
description: _(
msg`We encountered an unknown error while attempting to delete your document. Please try again later.`,
),
});
}
};
@@ -0,0 +1,141 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useCurrentTeam } from '~/providers/team';
type AiFeaturesEnableDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onEnabled: () => void;
};
export const AiFeaturesEnableDialog = ({
open,
onOpenChange,
onEnabled,
}: AiFeaturesEnableDialogProps) => {
const { t } = useLingui();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const isTeamAdmin = team.currentTeamRole === TeamMemberRole.ADMIN;
const isOrganisationAdmin = organisation.currentOrganisationRole === OrganisationMemberRole.ADMIN;
const canEnableAiFeatures = isTeamAdmin || isOrganisationAdmin;
const [error, setError] = useState<string | null>(null);
const { mutateAsync: updateTeamSettings, isPending: isUpdatingTeamSettings } =
trpc.team.settings.update.useMutation();
const { mutateAsync: updateOrganisationSettings, isPending: isUpdatingOrganisationSettings } =
trpc.organisation.settings.update.useMutation();
const isSubmitting = isUpdatingTeamSettings || isUpdatingOrganisationSettings;
const onEnableClick = async () => {
if (!canEnableAiFeatures) {
return;
}
setError(null);
try {
if (isTeamAdmin) {
await updateTeamSettings({
teamId: team.id,
data: { aiFeaturesEnabled: true },
});
} else {
await updateOrganisationSettings({
organisationId: organisation.id,
data: { aiFeaturesEnabled: true },
});
}
onEnabled();
onOpenChange(false);
} catch (err) {
console.error('Failed to enable AI features', err);
setError(
err instanceof Error
? err.message
: t`We couldn't enable AI features right now. Please try again.`,
);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
<Trans>Enable AI features</Trans>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>
Turn on AI detection to automatically find recipients and fields in your documents. AI
providers do not retain your data for training.
</Trans>
</p>
<Alert variant="neutral">
<AlertDescription>
<Trans>
Your document content will be sent securely to our AI provider solely for detection
and will not be stored or used for training.
</Trans>
</AlertDescription>
</Alert>
{canEnableAiFeatures ? (
<p className="text-sm text-muted-foreground">
<Trans>
You're an admin. You can enable AI features for this team right away. Everyone on
the team will see AI detection once enabled.
</Trans>
</p>
) : (
<p className="text-sm text-muted-foreground">
<Trans>
AI features are disabled for your team. Please ask your team owner or organisation
owner to enable them.
</Trans>
</p>
)}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
{canEnableAiFeatures ? (
<Button type="button" onClick={() => void onEnableClick()} loading={isSubmitting}>
<Trans>Enable AI features</Trans>
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,381 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import {
AiApiError,
type DetectFieldsProgressEvent,
detectFields,
} from '../../../server/api/ai/detect-fields.client';
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
type AiFieldDetectionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: (fields: NormalizedFieldWithContext[]) => void;
envelopeId: string;
teamId: number;
};
const PROCESSING_MESSAGES = [
msg`Reading your document`,
msg`Analyzing page layout`,
msg`Looking for form fields`,
msg`Detecting signature areas`,
msg`Identifying input fields`,
msg`Mapping fields to recipients`,
msg`Almost done`,
] as const;
const FIELD_TYPE_LABELS: Record<string, MessageDescriptor> = {
SIGNATURE: msg`Signature`,
INITIALS: msg`Initials`,
NAME: msg`Name`,
EMAIL: msg`Email`,
DATE: msg`Date`,
TEXT: msg`Text`,
NUMBER: msg`Number`,
CHECKBOX: msg`Checkbox`,
RADIO: msg`Radio`,
};
export const AiFieldDetectionDialog = ({
open,
onOpenChange,
onComplete,
envelopeId,
teamId,
}: AiFieldDetectionDialogProps) => {
const { _ } = useLingui();
const [state, setState] = useState<DialogState>('PROMPT');
const [messageIndex, setMessageIndex] = useState(0);
const [detectedFields, setDetectedFields] = useState<NormalizedFieldWithContext[]>([]);
const [error, setError] = useState<string | null>(null);
const [context, setContext] = useState('');
const [progress, setProgress] = useState<DetectFieldsProgressEvent | null>(null);
const onDetectClick = useCallback(async () => {
setState('PROCESSING');
setMessageIndex(0);
setError(null);
setProgress(null);
try {
await detectFields({
request: {
envelopeId,
teamId,
context: context || undefined,
},
onProgress: (progressEvent) => {
setProgress(progressEvent);
},
onComplete: (event) => {
setDetectedFields(event.fields);
setState('REVIEW');
},
onError: (err) => {
console.error('Detection failed:', err);
if (err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err.message);
setState('ERROR');
},
});
} catch (err) {
console.error('Detection failed:', err);
if (err instanceof AiApiError && err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err instanceof Error ? err.message : 'Failed to detect fields');
setState('ERROR');
}
}, [envelopeId, teamId, context]);
const onAddFields = () => {
onComplete(detectedFields);
onOpenChange(false);
setState('PROMPT');
setDetectedFields([]);
setContext('');
};
const onClose = () => {
onOpenChange(false);
setState('PROMPT');
setDetectedFields([]);
setError(null);
setContext('');
setProgress(null);
};
// Group fields by type for summary display
const fieldCountsByType = useMemo(() => {
const counts: Record<string, number> = {};
for (const field of detectedFields) {
counts[field.type] = (counts[field.type] || 0) + 1;
}
return Object.entries(counts).sort(([, a], [, b]) => b - a);
}, [detectedFields]);
useEffect(() => {
if (state !== 'PROCESSING') {
return;
}
const interval = setInterval(() => {
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
}, 4000);
return () => clearInterval(interval);
}, [state]);
return (
<Dialog open={open}>
<DialogContent className="sm:max-w-lg" hideClose={true}>
{state === 'PROMPT' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detect fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>
We'll scan your document to find form fields like signature lines, text inputs,
checkboxes, and more. Detected fields will be suggested for you to review.
</Trans>
</p>
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
<AlertDescription className="mt-0">
<Trans>
Your document is processed securely using AI services that don't retain your
data.
</Trans>
</AlertDescription>
</Alert>
<div className="space-y-1.5">
<Label htmlFor="context">
<Trans>Context</Trans>
</Label>
<Textarea
id="context"
placeholder={_(msg`David is the Employee, Lucas is the Manager`)}
value={context}
onChange={(e) => setContext(e.target.value)}
rows={2}
className="resize-none"
/>
<p className="text-xs text-muted-foreground">
<Trans>Help the AI assign fields to the right recipients.</Trans>
</p>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Skip</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Detect</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'PROCESSING' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detecting fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center py-8">
<AnimatedDocumentScanner />
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
{progress && (
<p className="mt-2 text-xs text-muted-foreground/60">
<Plural
value={progress.fieldsDetected}
one={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # field found
</Trans>
}
other={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # fields found
</Trans>
}
/>
</p>
)}
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
<Trans>This can take a minute or two depending on the size of your document.</Trans>
</p>
<div className="mt-4 flex gap-1">
{PROCESSING_MESSAGES.map((_, index) => (
<div
key={index}
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
}`}
/>
))}
</div>
</div>
</>
)}
{state === 'REVIEW' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detected fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto">
{detectedFields.length === 0 ? (
<div className="flex flex-col items-center py-8">
<FormInputIcon className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-center text-sm text-muted-foreground">
<Trans>No fields were detected in your document.</Trans>
</p>
<p className="mt-1 text-center text-xs text-muted-foreground/70">
<Trans>You can add fields manually in the editor.</Trans>
</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
<Plural
value={detectedFields.length}
one="We found # field in your document."
other="We found # fields in your document."
/>
</p>
<ul className="mt-4 divide-y rounded-lg border">
{fieldCountsByType.map(([type, count]) => (
<li key={type} className="flex items-center justify-between px-4 py-3">
<span className="text-sm">{_(FIELD_TYPE_LABELS[type]) || type}</span>
<span className="text-sm font-medium text-muted-foreground">{count}</span>
</li>
))}
</ul>
</>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Cancel</Trans>
</Button>
{detectedFields.length > 0 && (
<Button type="button" onClick={onAddFields}>
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Add fields</Trans>
</Button>
)}
</DialogFooter>
</>
)}
{state === 'ERROR' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detection failed</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>Something went wrong while detecting fields.</Trans>
</p>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'RATE_LIMITED' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Too many requests</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>
You've made too many detection requests. Please wait a minute before trying again.
</Trans>
</p>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,372 @@
import { useCallback, useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
AiApiError,
type DetectRecipientsProgressEvent,
detectRecipients,
} from '../../../server/api/ai/detect-recipients.client';
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
type AiRecipientDetectionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: (recipients: TDetectedRecipientSchema[]) => void;
envelopeId: string;
teamId: number;
};
const PROCESSING_MESSAGES = [
msg`Reading your document`,
msg`Analyzing pages`,
msg`Looking for signature fields`,
msg`Identifying recipients`,
msg`Extracting contact details`,
msg`Almost done`,
] as const;
export const AiRecipientDetectionDialog = ({
open,
onOpenChange,
onComplete,
envelopeId,
teamId,
}: AiRecipientDetectionDialogProps) => {
const { _ } = useLingui();
const [state, setState] = useState<DialogState>('PROMPT');
const [messageIndex, setMessageIndex] = useState(0);
const [detectedRecipients, setDetectedRecipients] = useState<TDetectedRecipientSchema[]>([]);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<DetectRecipientsProgressEvent | null>(null);
const onDetectClick = useCallback(async () => {
setState('PROCESSING');
setMessageIndex(0);
setError(null);
setProgress(null);
try {
await detectRecipients({
request: {
envelopeId,
teamId,
},
onProgress: (progressEvent) => {
setProgress(progressEvent);
},
onComplete: (event) => {
setDetectedRecipients(event.recipients);
setState('REVIEW');
},
onError: (err) => {
console.error('Detection failed:', err);
if (err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err.message);
setState('ERROR');
},
});
} catch (err) {
console.error('Detection failed:', err);
if (err instanceof AiApiError && err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err instanceof Error ? err.message : 'Failed to detect recipients');
setState('ERROR');
}
}, [envelopeId, teamId]);
const handleRemoveRecipient = (index: number) => {
setDetectedRecipients((prev) => prev.filter((_, i) => i !== index));
};
const onAddRecipients = () => {
onComplete(detectedRecipients);
onOpenChange(false);
setState('PROMPT');
setDetectedRecipients([]);
};
const onClose = () => {
onOpenChange(false);
setState('PROMPT');
setDetectedRecipients([]);
setError(null);
setProgress(null);
};
useEffect(() => {
if (state !== 'PROCESSING') {
return;
}
const interval = setInterval(() => {
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
}, 4000);
return () => clearInterval(interval);
}, [state]);
return (
<Dialog open={open}>
<DialogContent className="sm:max-w-lg" hideClose={true}>
{state === 'PROMPT' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detect recipients</Trans>
</DialogTitle>
</DialogHeader>
<div>
<p className="text-sm text-muted-foreground">
<Trans>
We'll scan your document to find signature fields and identify who needs to sign.
Detected recipients will be suggested for you to review.
</Trans>
</p>
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
<AlertDescription className="mt-0">
<Trans>
Your document is processed securely using AI services that don't retain your
data.
</Trans>
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Skip</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Detect</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'PROCESSING' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detecting recipients</Trans>
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center py-8">
<AnimatedDocumentScanner />
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
{progress && (
<p className="mt-2 text-xs text-muted-foreground/60">
<Plural
value={progress.recipientsDetected}
one={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # recipient found
</Trans>
}
other={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # recipients found
</Trans>
}
/>
</p>
)}
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
<Trans>This can take a minute or two depending on the size of your document.</Trans>
</p>
<div className="mt-4 flex gap-1">
{PROCESSING_MESSAGES.map((_, index) => (
<div
key={index}
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
}`}
/>
))}
</div>
</div>
</>
)}
{state === 'REVIEW' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detected recipients</Trans>
</DialogTitle>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto">
{detectedRecipients.length === 0 ? (
<div className="flex flex-col items-center py-8">
<UserIcon className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-center text-sm text-muted-foreground">
<Trans>No recipients were detected in your document.</Trans>
</p>
<p className="mt-1 text-center text-xs text-muted-foreground/70">
<Trans>You can add recipients manually in the editor.</Trans>
</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
<Plural
value={detectedRecipients.length}
one="We found # recipient in your document."
other="We found # recipients in your document."
/>
</p>
<ul className="mt-4 divide-y rounded-lg border">
{detectedRecipients.map((recipient, index) => (
<li key={index} className="flex items-center justify-between px-4 py-3">
<AvatarWithText
avatarFallback={
recipient.name
? recipient.name.slice(0, 1).toUpperCase()
: recipient.email
? recipient.email.slice(0, 1).toUpperCase()
: '?'
}
primaryText={
<p className="text-sm font-medium text-foreground">
{recipient.name || _(msg`Unknown name`)}
</p>
}
secondaryText={
<div className="text-xs text-muted-foreground">
<p className="italic text-muted-foreground/70">
{recipient.email || _(msg`No email detected`)}
</p>
<p>{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
}
/>
<button
type="button"
className="h-8 w-8 p-0 text-muted-foreground/80 hover:text-destructive focus-visible:border-destructive focus-visible:ring-destructive"
onClick={() => handleRemoveRecipient(index)}
>
<span className="sr-only">
<Trans>Remove recipient</Trans>
</span>
<XIcon className="h-4 w-4" aria-hidden="true" />
</button>
</li>
))}
</ul>
</>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Cancel</Trans>
</Button>
{detectedRecipients.length > 0 && (
<Button type="button" onClick={onAddRecipients}>
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Add recipients</Trans>
</Button>
)}
</DialogFooter>
</>
)}
{state === 'ERROR' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detection failed</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>Something went wrong while detecting recipients.</Trans>
</p>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'RATE_LIMITED' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Too many requests</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>
You've made too many detection requests. Please wait a minute before trying again.
</Trans>
</p>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -13,7 +13,7 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
@@ -96,7 +96,7 @@ export const DocumentDuplicateDialog = ({
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<PDFViewer
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={undefined}
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -7,9 +7,7 @@ import {
DocumentDistributionMethod,
DocumentStatus,
EnvelopeType,
type Field,
FieldType,
type Recipient,
RecipientRole,
} from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
@@ -19,8 +17,9 @@ import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
@@ -52,16 +51,13 @@ import {
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopeDistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
recipients: Recipient[];
fields: Pick<Field, 'type' | 'recipientId'>[];
};
onDistribute?: () => Promise<void>;
documentRootPath: string;
trigger?: React.ReactNode;
@@ -86,20 +82,20 @@ export const ZEnvelopeDistributeFormSchema = z.object({
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
export const EnvelopeDistributeDialog = ({
envelope,
trigger,
documentRootPath,
onDistribute,
}: EnvelopeDistributeDialogProps) => {
const organisation = useCurrentOrganisation();
const recipients = envelope.recipients;
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
const { toast } = useToast();
const { t } = useLingui();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation();
@@ -134,18 +130,43 @@ export const EnvelopeDistributeDialog = ({
const distributionMethod = watch('meta.distributionMethod');
const recipientsWithIndex = useMemo(
() =>
envelope.recipients.map((recipient, index) => ({
...recipient,
index,
})),
[envelope.recipients],
);
const recipientsMissingSignatureFields = useMemo(
() =>
envelope.recipients.filter(
recipientsWithIndex.filter(
(recipient) =>
recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
[envelope.recipients, envelope.fields],
[recipientsWithIndex, envelope.fields],
);
/**
* List of recipients who must have an email due to having auth enabled.
*/
const recipientsMissingRequiredEmail = useMemo(() => {
return recipientsWithIndex.filter((recipient) => {
const auth = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
return (
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email
);
});
}, [recipientsWithIndex, envelope.authOptions]);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -155,8 +176,12 @@ export const EnvelopeDistributeDialog = ({
return 'MISSING_RECIPIENTS';
}
if (recipientsMissingRequiredEmail.length > 0) {
return 'MISSING_REQUIRED_EMAIL';
}
return null;
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try {
@@ -189,6 +214,29 @@ export const EnvelopeDistributeDialog = ({
}
};
const handleSync = async () => {
if (isSyncing) {
return;
}
setIsSyncing(true);
try {
await syncEnvelope();
} catch (err) {
console.error(err);
}
setIsSyncing(false);
};
useEffect(() => {
// Resync the whole envelope if the envelope is mid saving.
if (isOpen && (isAutosaving || autosaveError)) {
void handleSync();
}
}, [isOpen]);
if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) {
return null;
}
@@ -208,7 +256,7 @@ export const EnvelopeDistributeDialog = ({
</DialogDescription>
</DialogHeader>
{!invalidEnvelopeCode ? (
{!invalidEnvelopeCode || isSyncing ? (
<Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}>
@@ -236,7 +284,16 @@ export const EnvelopeDistributeDialog = ({
})}
>
<AnimatePresence initial={false} mode="wait">
{distributionMethod === DocumentDistributionMethod.EMAIL && (
{isSyncing ? (
<motion.div
key={'Flushing'}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
>
<SpinnerBox spinnerProps={{ size: 'sm' }} className="h-72" />
</motion.div>
) : distributionMethod === DocumentDistributionMethod.EMAIL ? (
<motion.div
key={'Emails'}
initial={{ opacity: 0, y: 5 }}
@@ -339,7 +396,7 @@ export const EnvelopeDistributeDialog = ({
<TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground p-4">
<TooltipContent className="p-4 text-muted-foreground">
<DocumentSendEmailMessageHelper />
</TooltipContent>
</Tooltip>
@@ -347,7 +404,7 @@ export const EnvelopeDistributeDialog = ({
<FormControl>
<Textarea
className="bg-background mt-2 h-16 resize-none"
className="mt-2 h-16 resize-none bg-background"
{...field}
maxLength={5000}
/>
@@ -359,9 +416,7 @@ export const EnvelopeDistributeDialog = ({
</fieldset>
</Form>
</motion.div>
)}
{distributionMethod === DocumentDistributionMethod.NONE && (
) : distributionMethod === DocumentDistributionMethod.NONE ? (
<motion.div
key={'Links'}
initial={{ opacity: 0, y: 5 }}
@@ -369,7 +424,7 @@ export const EnvelopeDistributeDialog = ({
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="min-h-60 rounded-lg border"
>
<div className="text-muted-foreground py-24 text-center text-sm">
<div className="py-24 text-center text-sm text-muted-foreground">
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
@@ -382,7 +437,7 @@ export const EnvelopeDistributeDialog = ({
</p>
</div>
</motion.div>
)}
) : null}
</AnimatePresence>
</div>
@@ -393,7 +448,7 @@ export const EnvelopeDistributeDialog = ({
</Button>
</DialogClose>
<Button loading={isSubmitting} type="submit">
<Button loading={isSubmitting} disabled={isSyncing} type="submit">
{distributionMethod === DocumentDistributionMethod.EMAIL ? (
<Trans>Send</Trans>
) : (
@@ -419,7 +474,22 @@ export const EnvelopeDistributeDialog = ({
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>{recipient.email}</li>
<li key={recipient.id}>
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
</li>
))}
</ul>
</AlertDescription>
))
.with('MISSING_REQUIRED_EMAIL', () => (
<AlertDescription>
<Trans>The following recipients require an email address:</Trans>
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingRequiredEmail.map((recipient) => (
<li key={recipient.id}>
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
</li>
))}
</ul>
</AlertDescription>
@@ -367,7 +367,7 @@ const BillingPlanForm = ({
<div className="w-full text-left">
<div className="flex items-center justify-between">
<p className="text-medium">
<Trans>Free</Trans>
<Trans context="Plan price">Free</Trans>
</p>
<Badge size="small" variant="neutral" className="ml-1.5">
@@ -24,7 +24,10 @@ import {
import { Input } from '@documenso/ui/primitives/input';
const ZSignFieldEmailFormSchema = z.object({
email: z.string().min(1, { message: msg`Email is required`.id }),
email: z
.string()
.email()
.min(1, { message: msg`Email is required`.id }),
});
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
@@ -103,8 +103,8 @@ export const TemplateBulkSendDialog = ({
console.error(err);
toast({
title: 'Error',
description: 'Failed to upload CSV. Please check the file format and try again.',
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
variant: 'destructive',
});
}
@@ -21,6 +21,7 @@ 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';
@@ -65,7 +66,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
@@ -100,12 +101,29 @@ export function TemplateUseDialog({
const [open, setOpen] = useState(false);
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
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 {
distributeDocument: false,
useCustomDocument: false,
customDocumentData: [],
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
envelopeItemId: item.id,
})),
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
@@ -124,7 +142,12 @@ export function TemplateUseDialog({
signingOrder: recipient.signingOrder ?? undefined,
};
}),
},
};
};
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: generateDefaultFormValues(),
});
const { replace, fields: localCustomDocumentData } = useFieldArray({
@@ -132,19 +155,6 @@ 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();
@@ -214,8 +224,8 @@ export function TemplateUseDialog({
});
useEffect(() => {
if (!open) {
form.reset();
if (open) {
form.reset(generateDefaultFormValues());
}
}, [open, form]);
@@ -322,7 +332,7 @@ export function TemplateUseDialog({
<Input
{...field}
aria-label="Name"
placeholder={recipients[index].name || _(msg`Name`)}
placeholder={recipients[index].name || _(msg`Recipient ${index + 1}`)}
/>
</FormControl>
<FormMessage />
@@ -349,7 +359,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="distributeDocument"
>
<Trans>Send document</Trans>
@@ -358,7 +368,7 @@ export function TemplateUseDialog({
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
The document will be immediately sent to recipients if this
@@ -378,7 +388,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
@@ -386,7 +396,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Create the document as pending and ready to sign.
@@ -432,7 +442,7 @@ export function TemplateUseDialog({
}}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="useCustomDocument"
>
<Trans>Upload custom document</Trans>
@@ -440,7 +450,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Upload a custom document to use instead of the template's default
@@ -470,19 +480,19 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
>
<div className="flex-shrink-0">
<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 className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
</div>
</div>
<div className="min-w-0 flex-1">
<h4 className="text-foreground truncate text-sm font-medium">
<h4 className="truncate text-sm font-medium text-foreground">
{item.title}
</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<p className="mt-0.5 text-xs text-muted-foreground">
{field.value ? (
<div>
<Trans>
@@ -38,7 +38,7 @@ import { useCurrentTeam } from '~/providers/team';
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true });
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema;
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
@@ -78,7 +78,6 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
eventTriggers,
secret,
webhookUrl,
teamId: team.id,
});
setOpen(false);
@@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
const onSubmit = async () => {
try {
await deleteWebhook({ id: webhook.id, teamId: team.id });
await deleteWebhook({ id: webhook.id });
toast({
title: _(msg`Webhook deleted`),
@@ -146,26 +146,18 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
/>
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setOpen(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>I'm sure! Delete it</Trans>
</Button>
</div>
<Button
type="submit"
variant="destructive"
disabled={!form.formState.isValid}
loading={form.formState.isSubmitting}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
@@ -0,0 +1,225 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZEditWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
const ZEditWebhookFormSchema = ZEditWebhookRequestSchema.omit({ id: true });
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
export type WebhookEditDialogProps = {
trigger?: React.ReactNode;
webhook: Webhook;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const WebhookEditDialog = ({ trigger, webhook, ...props }: WebhookEditDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
const form = useForm<TEditWebhookFormSchema>({
resolver: zodResolver(ZEditWebhookFormSchema),
values: {
webhookUrl: webhook?.webhookUrl ?? '',
eventTriggers: webhook?.eventTriggers ?? [],
secret: webhook?.secret ?? '',
enabled: webhook?.enabled ?? true,
},
});
const onSubmit = async (data: TEditWebhookFormSchema) => {
try {
await updateWebhook({
id: webhook.id,
...data,
});
toast({
title: t`Webhook updated`,
description: t`The webhook has been updated successfully.`,
duration: 5000,
});
} catch (err) {
toast({
title: t`Failed to update webhook`,
description: t`We encountered an error while updating the webhook. Please try again later.`,
variant: 'destructive',
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
{...props}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
<DialogContent className="max-w-lg" position="center">
<DialogHeader>
<DialogTitle>
<Trans>Edit webhook</Trans>
</DialogTitle>
<DialogDescription>{webhook.id}</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Webhook URL</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
<Trans>The URL for Documenso to send webhook events to.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>
<Trans>Triggers</Trans>
</FormLabel>
<FormControl>
<WebhookMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormDescription>
<Trans>The events that will trigger a webhook to be sent to your URL.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput
className="bg-background"
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormDescription>
<Trans>
A secret that will be sent to your URL so you can verify that the request
has been sent by Documenso.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -36,8 +36,6 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export type WebhookTestDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl' | 'eventTriggers'>;
children: React.ReactNode;
@@ -53,8 +51,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
const { t } = useLingui();
const { toast } = useToast();
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
const { mutateAsync: testWebhook } = trpc.webhook.testWebhook.useMutation();
@@ -71,7 +67,6 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
await testWebhook({
id: webhook.id,
event,
teamId: team.id,
});
toast({
@@ -150,11 +145,11 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
<Trans>Close</Trans>
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Send Test Webhook</Trans>
<Trans>Send</Trans>
</Button>
</DialogFooter>
</fieldset>
@@ -5,6 +5,7 @@ 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
@@ -55,7 +56,7 @@ export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.exten
nativeId: z.number().optional(),
formId: z.string(),
name: z.string(),
email: z.union([z.string().length(0), z.string().email('Invalid email address')]),
email: ZRecipientEmailSchema,
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
@@ -1,8 +1,9 @@
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 { DocumentData, FieldType } from '@prisma/client';
import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base';
import { ChevronsUpDown } from 'lucide-react';
@@ -23,7 +24,7 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
import { Form } from '@documenso/ui/primitives/form/form';
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -40,7 +41,8 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema;
documentData?: DocumentData;
presignToken?: string | undefined;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack?: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void;
@@ -48,7 +50,8 @@ export type ConfigureFieldsViewProps = {
export const ConfigureFieldsView = ({
configData,
documentData,
presignToken,
envelopeItem,
defaultValues,
onBack,
onSubmit,
@@ -82,17 +85,25 @@ export const ConfigureFieldsView = ({
}, []);
const normalizedDocumentData = useMemo(() => {
if (documentData) {
return documentData.data;
if (envelopeItem) {
return undefined;
}
if (!configData.documentData) {
return null;
return undefined;
}
return base64.encode(configData.documentData.data);
}, [configData.documentData]);
const normalizedEnvelopeItem = useMemo(() => {
if (envelopeItem) {
return envelopeItem;
}
return { id: '', envelopeId: '' };
}, [envelopeItem]);
const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({
id: signer.nativeId || index,
@@ -219,8 +230,8 @@ export const ConfigureFieldsView = ({
setFieldClipboard(lastActiveField);
toast({
title: 'Copied field',
description: 'Copied field to clipboard',
title: _(msg`Copied field`),
description: _(msg`Copied field to clipboard`),
});
}
},
@@ -453,12 +464,12 @@ export const ConfigureFieldsView = ({
{/* Desktop sidebar */}
{!isMobile && (
<div className="order-2 col-span-12 md:order-1 md:col-span-4">
<div className="bg-widget border-border sticky top-4 max-h-[calc(100vh-2rem)] rounded-lg border p-4 pb-6">
<div className="sticky top-4 max-h-[calc(100vh-2rem)] rounded-lg border border-border bg-widget p-4 pb-6">
<h2 className="mb-1 text-lg font-medium">
<Trans>Configure Fields</Trans>
</h2>
<p className="text-muted-foreground mb-6 text-sm">
<p className="mb-6 text-sm text-muted-foreground">
<Trans>Configure the fields you want to place on the document.</Trans>
</p>
@@ -512,7 +523,7 @@ export const ConfigureFieldsView = ({
{selectedField && (
<div
className={cn(
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white transition duration-200 [container-type:size]',
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size]',
selectedRecipientStyles.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
@@ -534,56 +545,50 @@ export const ConfigureFieldsView = ({
)}
<Form {...form}>
{normalizedDocumentData && (
<div>
<PDFViewer
overrideData={normalizedDocumentData}
envelopeItem={{
id: '',
envelopeId: '',
}}
token={undefined}
version="signed"
/>
<div>
<PDFViewerLazy
presignToken={presignToken}
overrideData={normalizedDocumentData}
envelopeItem={normalizedEnvelopeItem}
token={undefined}
version="signed"
/>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex(
(r) => r.id === field.recipientId,
);
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
return (
<FieldItem
key={field.formId}
field={field}
minHeight={MIN_HEIGHT_PX}
minWidth={MIN_WIDTH_PX}
defaultHeight={DEFAULT_HEIGHT_PX}
defaultWidth={DEFAULT_WIDTH_PX}
onResize={(node) => onFieldResize(node, index)}
onMove={(node) => onFieldMove(node, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onAdvancedSettings={() => {
setCurrentField(field);
setShowAdvancedSettings(true);
}}
recipientIndex={recipientIndex}
active={activeFieldId === field.formId}
onFieldActivate={() => setActiveFieldId(field.formId)}
onFieldDeactivate={() => setActiveFieldId(null)}
disabled={selectedRecipient?.id !== field.recipientId}
/>
);
})}
</ElementVisible>
</div>
)}
return (
<FieldItem
key={field.formId}
field={field}
minHeight={MIN_HEIGHT_PX}
minWidth={MIN_WIDTH_PX}
defaultHeight={DEFAULT_HEIGHT_PX}
defaultWidth={DEFAULT_WIDTH_PX}
onResize={(node) => onFieldResize(node, index)}
onMove={(node) => onFieldMove(node, index)}
onRemove={() => remove(index)}
onDuplicate={() => onFieldCopy(null, { duplicate: true })}
onDuplicateAllPages={() => onFieldCopy(null, { duplicateAll: true })}
onFocus={() => setLastActiveField(field)}
onBlur={() => setLastActiveField(null)}
onAdvancedSettings={() => {
setCurrentField(field);
setShowAdvancedSettings(true);
}}
recipientIndex={recipientIndex}
active={activeFieldId === field.formId}
onFieldActivate={() => setActiveFieldId(field.formId)}
onFieldDeactivate={() => setActiveFieldId(null)}
disabled={selectedRecipient?.id !== field.recipientId}
/>
);
})}
</ElementVisible>
</div>
</Form>
</div>
</div>
@@ -593,14 +598,14 @@ export const ConfigureFieldsView = ({
{isMobile && (
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<SheetTrigger asChild>
<div className="bg-widget border-border fixed bottom-6 left-6 right-6 z-50 flex items-center justify-between gap-2 rounded-lg border p-4">
<div className="fixed bottom-6 left-6 right-6 z-50 flex items-center justify-between gap-2 rounded-lg border border-border bg-widget p-4">
<span className="text-lg font-medium">
<Trans>Configure Fields</Trans>
</span>
<button
type="button"
className="border-border text-muted-foreground inline-flex h-10 w-10 items-center justify-center rounded-lg border"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border text-muted-foreground"
>
<ChevronsUpDown className="h-6 w-6" />
</button>
@@ -610,13 +615,13 @@ export const ConfigureFieldsView = ({
<SheetContent
position="bottom"
size="xl"
className="bg-widget h-fit max-h-[80vh] overflow-y-auto rounded-t-xl p-4"
className="h-fit max-h-[80vh] overflow-y-auto rounded-t-xl bg-widget p-4"
>
<h2 className="mb-1 text-lg font-medium">
<Trans>Configure Fields</Trans>
</h2>
<p className="text-muted-foreground mb-6 text-sm">
<p className="mb-6 text-sm text-muted-foreground">
<Trans>Configure the fields you want to place on the document.</Trans>
</p>
@@ -28,7 +28,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -334,7 +334,7 @@ export const EmbedDirectTemplateClientPage = ({
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="flex-1">
<PDFViewer
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={recipient.token}
version="signed"
@@ -348,11 +348,11 @@ export const EmbedDirectTemplateClientPage = ({
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
<div className="flex h-fit w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
{/* Header */}
<div>
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
<Trans>Sign document</Trans>
</h3>
@@ -362,7 +362,7 @@ export const EmbedDirectTemplateClientPage = ({
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)}
>
<LucideChevronDown className="text-muted-foreground h-5 w-5" />
<LucideChevronDown className="h-5 w-5 text-muted-foreground" />
</Button>
) : pendingFields.length > 0 ? (
<Button
@@ -370,7 +370,7 @@ export const EmbedDirectTemplateClientPage = ({
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground h-5 w-5" />
<LucideChevronUp className="h-5 w-5 text-muted-foreground" />
</Button>
) : (
<Button
@@ -388,11 +388,11 @@ export const EmbedDirectTemplateClientPage = ({
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-sm text-muted-foreground">
<Trans>Sign the document to complete the process.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
<hr className="mb-8 mt-4 border-border" />
</div>
{/* Form */}
@@ -406,7 +406,7 @@ export const EmbedDirectTemplateClientPage = ({
<Input
type="text"
id="full-name"
className="bg-background mt-2"
className="mt-2 bg-background"
disabled={isNameLocked}
value={fullName}
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
@@ -421,7 +421,7 @@ export const EmbedDirectTemplateClientPage = ({
<Input
type="email"
id="email"
className="bg-background mt-2"
className="mt-2 bg-background"
disabled={isEmailLocked}
value={email}
onChange={(e) => !isEmailLocked && setEmail(e.target.value.trim())}
@@ -490,7 +490,7 @@ export const EmbedDirectTemplateClientPage = ({
</div>
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
@@ -22,7 +22,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -286,7 +286,7 @@ export const EmbedSignDocumentV1ClientPage = ({
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
<PDFViewer
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
@@ -300,11 +300,11 @@ export const EmbedSignDocumentV1ClientPage = ({
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
<div className="embed--DocumentWidget flex w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:py-6">
{/* Header */}
<div className="embed--DocumentWidgetHeader">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
{isAssistantMode ? (
<Trans>Assist with signing</Trans>
) : (
@@ -315,18 +315,18 @@ export const EmbedSignDocumentV1ClientPage = ({
{isExpanded ? (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
className="h-8 w-8 bg-background p-0 md:hidden dark:bg-foreground"
onClick={() => setIsExpanded(false)}
>
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
<LucideChevronDown className="h-5 w-5 text-muted-foreground dark:text-background" />
</Button>
) : pendingFields.length > 0 ? (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
className="h-8 w-8 bg-background p-0 md:hidden dark:bg-foreground"
onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
<LucideChevronUp className="h-5 w-5 text-muted-foreground dark:text-background" />
</Button>
) : (
<Button
@@ -346,7 +346,7 @@ export const EmbedSignDocumentV1ClientPage = ({
</div>
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-sm text-muted-foreground">
{isAssistantMode ? (
<Trans>Help complete the document for other signers.</Trans>
) : (
@@ -354,7 +354,7 @@ export const EmbedSignDocumentV1ClientPage = ({
)}
</p>
<hr className="border-border mb-8 mt-4" />
<hr className="mb-8 mt-4 border-border" />
</div>
{/* Form */}
@@ -366,7 +366,7 @@ export const EmbedSignDocumentV1ClientPage = ({
<Trans>Signing for</Trans>
</Label>
<fieldset className="dark:bg-background border-border mt-2 rounded-2xl border bg-white p-3">
<fieldset className="mt-2 rounded-2xl border border-border bg-white p-3 dark:bg-background">
<RadioGroup
className="gap-0 space-y-3 shadow-none"
value={selectedSignerId?.toString()}
@@ -377,7 +377,7 @@ export const EmbedSignDocumentV1ClientPage = ({
.map((r) => (
<div
key={`${assistantSignersId}-${r.id}`}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -395,15 +395,15 @@ export const EmbedSignDocumentV1ClientPage = ({
{r.name}
{r.id === recipient.id && (
<span className="text-muted-foreground ml-2">
<span className="ml-2 text-muted-foreground">
{_(msg`(You)`)}
</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
<p className="text-xs text-muted-foreground">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
<div className="text-xs leading-[inherit] text-muted-foreground">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
@@ -424,7 +424,7 @@ export const EmbedSignDocumentV1ClientPage = ({
<Input
type="text"
id="full-name"
className="bg-background mt-2"
className="mt-2 bg-background"
disabled={isNameLocked}
value={fullName}
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
@@ -439,7 +439,7 @@ export const EmbedSignDocumentV1ClientPage = ({
<Input
type="email"
id="email"
className="bg-background mt-2"
className="mt-2 bg-background"
value={email}
disabled
/>
@@ -507,7 +507,7 @@ export const EmbedSignDocumentV1ClientPage = ({
</div>
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
@@ -21,7 +21,7 @@ import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import PDFViewer from '@documenso/ui/primitives/pdf-viewer';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -150,8 +150,8 @@ export const MultiSignDocumentSigningView = ({
onDocumentError?.();
toast({
title: 'Error',
description: 'Failed to complete the document. Please try again.',
title: _(msg`Error`),
description: _(msg`Failed to complete the document. Please try again.`),
variant: 'destructive',
});
} finally {
@@ -177,14 +177,14 @@ export const MultiSignDocumentSigningView = ({
};
return (
<div className="bg-background min-h-screen overflow-hidden">
<div className="min-h-screen overflow-hidden bg-background">
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto p-8">
{match({ isLoading, document })
.with({ isLoading: true }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader className="text-primary h-8 w-8 animate-spin" />
<p className="text-muted-foreground text-sm">
<Loader className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
<Trans>Loading document...</Trans>
</p>
</div>
@@ -192,7 +192,7 @@ export const MultiSignDocumentSigningView = ({
))
.with({ isLoading: false, document: undefined }, () => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
<Trans>Failed to load document</Trans>
</p>
</div>
@@ -225,7 +225,7 @@ export const MultiSignDocumentSigningView = ({
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
})}
>
<PDFViewer
<PDFViewerLazy
envelopeItem={document.envelopeItems[0]}
token={token}
version="signed"
@@ -243,23 +243,23 @@ export const MultiSignDocumentSigningView = ({
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-0 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
<div className="embed--DocumentWidget flex w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:py-6">
{/* Header */}
<div className="embed--DocumentWidgetHeader">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
<Trans>Sign document</Trans>
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
className="h-5 w-5 text-muted-foreground"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
className="h-5 w-5 text-muted-foreground"
onClick={() => setIsExpanded(true)}
/>
)}
@@ -268,11 +268,11 @@ export const MultiSignDocumentSigningView = ({
</div>
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-sm text-muted-foreground">
<Trans>Sign the document to complete the process.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
<hr className="mb-8 mt-4 border-border" />
</div>
{/* Form */}
@@ -288,7 +288,7 @@ export const MultiSignDocumentSigningView = ({
<Input
type="text"
id="full-name"
className="bg-background mt-2"
className="mt-2 bg-background"
disabled={isNameLocked}
value={fullName}
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
@@ -303,7 +303,7 @@ export const MultiSignDocumentSigningView = ({
<Input
type="email"
id="email"
className="bg-background mt-2"
className="mt-2 bg-background"
value={email}
disabled
/>
@@ -58,6 +58,7 @@ export type TDocumentPreferencesFormSchema = {
includeSigningCertificate: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
aiFeaturesEnabled: boolean | null;
};
type SettingsSubset = Pick<
@@ -72,11 +73,13 @@ type SettingsSubset = Pick<
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'aiFeaturesEnabled'
>;
export type DocumentPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
isAiFeaturesConfigured?: boolean;
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
@@ -84,6 +87,7 @@ export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
canInherit,
isAiFeaturesConfigured = false,
}: DocumentPreferencesFormProps) => {
const { t } = useLingui();
const { user, organisations } = useSession();
@@ -105,6 +109,7 @@ 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>({
@@ -120,6 +125,7 @@ export const DocumentPreferencesForm = ({
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
aiFeaturesEnabled: settings.aiFeaturesEnabled,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
@@ -312,7 +318,7 @@ export const DocumentPreferencesForm = ({
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
className="w-full bg-background"
enableSearch={false}
emptySelectionPlaceholder={
canInherit ? t`Inherit from organisation` : t`Select signature types`
@@ -378,7 +384,7 @@ export const DocumentPreferencesForm = ({
</FormControl>
<div className="pt-2">
<div className="text-muted-foreground text-xs font-medium">
<div className="text-xs font-medium text-muted-foreground">
<Trans>Preview</Trans>
</div>
@@ -509,6 +515,59 @@ 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 -1
View File
@@ -201,7 +201,7 @@ export const SignInForm = ({
.otherwise(() => handleFallbackErrorMessages(error.code));
toast({
title: 'Something went wrong',
title: _(msg`Something went wrong`),
description: _(errorMessage),
duration: 10000,
variant: 'destructive',
@@ -0,0 +1,78 @@
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-muted-foreground text-sm"
className="text-sm text-muted-foreground"
title={
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
}
>
<p>{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
<p>{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -42,7 +42,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { ZCreateOrganisationFormSchema } from '../dialogs/organisation-create-dialog';
const MotionCard = motion(Card);
const MotionCard = motion.create(Card);
export type BillingPlansProps = {
plans: InternalClaimPlans;
@@ -101,7 +101,7 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
<CardContent className="flex h-full flex-col p-6">
<CardTitle>{price.product.name}</CardTitle>
<div className="text-muted-foreground mt-2 text-lg font-medium">
<div className="mt-2 text-lg font-medium text-muted-foreground">
{price.friendlyPrice + ' '}
<span className="text-xs">
{interval === 'monthlyPrice' ? (
@@ -112,12 +112,12 @@ export const BillingPlans = ({ plans }: BillingPlansProps) => {
</span>
</div>
<div className="text-muted-foreground mt-1.5 text-sm">
<div className="mt-1.5 text-sm text-muted-foreground">
{price.product.description}
</div>
{price.product.features && price.product.features.length > 0 && (
<div className="text-muted-foreground mt-4">
<div className="mt-4 text-muted-foreground">
<div className="text-sm font-medium">Includes:</div>
<ul className="mt-1 divide-y text-sm">
@@ -261,7 +261,7 @@ const BillingDialog = ({
<Building2Icon className="h-4 w-4" />
<Trans>Update current organisation</Trans>
</Label>
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
<Trans>
Upgrade <strong>{organisation.name}</strong> to {planName}
</Trans>
@@ -276,7 +276,7 @@ const BillingDialog = ({
<PlusIcon className="h-4 w-4" />
<Trans>Create separate organisation</Trans>
</Label>
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
<Trans>
Create a new organisation with {planName} plan. Keep your current organisation
on it's current plan
@@ -13,7 +13,7 @@ import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -151,7 +151,7 @@ export const DirectTemplatePageView = ({
gradient
>
<CardContent className="p-2">
<PDFViewer
<PDFViewerLazy
key={template.id}
envelopeItem={template.envelopeItems[0]}
token={directTemplateRecipient.token}
@@ -57,12 +57,13 @@ export type DocumentSigningCompleteDialogProps = {
name: string;
email: string;
};
directTemplatePayload?: {
recipientPayload?: {
name: string;
email: string;
};
buttonSize?: 'sm' | 'lg';
position?: 'start' | 'end' | 'center';
disableNameInput?: boolean;
};
const ZNextSignerFormSchema = z.object({
@@ -89,10 +90,11 @@ export const DocumentSigningCompleteDialog = ({
recipient,
disabled = false,
allowDictateNextSigner = false,
directTemplatePayload,
recipientPayload,
defaultNextSigner,
buttonSize = 'lg',
position,
disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => {
const { t } = useLingui();
@@ -113,11 +115,11 @@ export const DocumentSigningCompleteDialog = ({
},
});
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
const recipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: {
name: directTemplatePayload?.name ?? '',
email: directTemplatePayload?.email ?? '',
name: recipientPayload?.name ?? '',
email: recipientPayload?.email ?? '',
},
});
@@ -145,16 +147,16 @@ export const DocumentSigningCompleteDialog = ({
const onFormSubmit = async (data: TNextSignerFormSchema) => {
try {
let directRecipient: { name: string; email: string } | undefined;
let recipientOverridePayload: { name: string; email: string } | undefined;
if (directTemplatePayload && !directTemplatePayload.email) {
const isFormValid = await directRecipientForm.trigger();
if (recipientPayload && !recipientPayload.email) {
const isFormValid = await recipientForm.trigger();
if (!isFormValid) {
return;
}
directRecipient = directRecipientForm.getValues();
recipientOverridePayload = recipientForm.getValues();
}
// Check if 2FA is required
@@ -168,7 +170,7 @@ export const DocumentSigningCompleteDialog = ({
? { name: data.name, email: data.email }
: undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
} catch (error) {
const err = AppError.parseError(error);
@@ -222,7 +224,7 @@ export const DocumentSigningCompleteDialog = ({
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<div className="text-muted-foreground max-w-[50ch]">
<div className="max-w-[50ch] text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<span className="inline-flex flex-wrap">
@@ -250,19 +252,19 @@ export const DocumentSigningCompleteDialog = ({
</DialogDescription>
</DialogHeader>
<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 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>
{!showTwoFactorForm && (
<>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
{directTemplatePayload && !directTemplatePayload.email && (
<Form {...directRecipientForm}>
{recipientPayload && !recipientPayload.email && (
<Form {...recipientForm}>
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={directRecipientForm.control}
control={recipientForm.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
@@ -274,7 +276,7 @@ export const DocumentSigningCompleteDialog = ({
{...field}
className="mt-2"
placeholder={t`Enter your name`}
disabled={isNameLocked}
disabled={isNameLocked || disableNameInput}
/>
</FormControl>
@@ -284,7 +286,7 @@ export const DocumentSigningCompleteDialog = ({
/>
<FormField
control={directRecipientForm.control}
control={recipientForm.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
@@ -108,8 +108,8 @@ export const DocumentSigningForm = ({
await completeDocument({ nextSigner });
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while completing the document. Please try again.',
title: _(msg`Error`),
description: _(msg`An error occurred while completing the document. Please try again.`),
variant: 'destructive',
});
@@ -135,7 +135,7 @@ export const DocumentSigningForm = ({
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
@@ -166,7 +166,7 @@ export const DocumentSigningForm = ({
) : recipient.role === RecipientRole.ASSISTANT ? (
<>
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
<fieldset className="rounded-2xl border border-border bg-white p-3 dark:bg-background">
<Controller
name="selectedSignerId"
control={assistantForm.control}
@@ -185,7 +185,7 @@ export const DocumentSigningForm = ({
.map((r) => (
<div
key={`${assistantSignersId}-${r.id}`}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -203,15 +203,15 @@ export const DocumentSigningForm = ({
{r.name}
{r.id === recipient.id && (
<span className="text-muted-foreground ml-2">
<span className="ml-2 text-muted-foreground">
{_(msg`(You)`)}
</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
<p className="text-xs text-muted-foreground">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
<div className="text-xs leading-[inherit] text-muted-foreground">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
@@ -265,7 +265,7 @@ export const DocumentSigningForm = ({
<Input
type="text"
id="full-name"
className="bg-background mt-2"
className="mt-2 bg-background"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
@@ -294,7 +294,7 @@ export const DocumentSigningForm = ({
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
@@ -30,7 +30,7 @@ import { DocumentReadOnlyFields } from '@documenso/ui/components/document/docume
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
@@ -187,45 +187,74 @@ 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="text-muted-foreground truncate" 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>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>
),
)
.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>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>
),
)
.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>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>
),
)
.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>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>
),
)
.otherwise(() => null)}
@@ -245,7 +274,7 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex-1">
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewer
<PDFViewerLazy
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={recipient.token}
@@ -260,9 +289,9 @@ export const DocumentSigningPageViewV1 = ({
className="group/document-widget fixed bottom-6 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-4 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
<div className="flex w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:py-6">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
@@ -305,25 +334,25 @@ export const DocumentSigningPageViewV1 = ({
.with({ isExpanded: true }, () => (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
className="h-8 w-8 bg-background p-0 md:hidden dark:bg-foreground"
onClick={() => setIsExpanded(false)}
>
<LucideChevronDown className="text-muted-foreground dark:text-background h-5 w-5" />
<LucideChevronDown className="h-5 w-5 text-muted-foreground dark:text-background" />
</Button>
))
.otherwise(() => (
<Button
variant="outline"
className="bg-background dark:bg-foreground h-8 w-8 p-0 md:hidden"
className="h-8 w-8 bg-background p-0 md:hidden dark:bg-foreground"
onClick={() => setIsExpanded(true)}
>
<LucideChevronUp className="text-muted-foreground dark:text-background h-5 w-5" />
<LucideChevronUp className="h-5 w-5 text-muted-foreground dark:text-background" />
</Button>
))}
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
<p className="mt-2 text-sm text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<Trans>Please mark as viewed to complete.</Trans>
@@ -340,7 +369,7 @@ export const DocumentSigningPageViewV1 = ({
.otherwise(() => null)}
</p>
<hr className="border-border mb-8 mt-4" />
<hr className="mb-8 mt-4 border-border" />
</div>
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
@@ -34,7 +34,7 @@ import { DocumentSigningRejectDialog } from './document-signing-reject-dialog';
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
const EnvelopeSignerPageRenderer = lazy(
async () => import('../envelope-signing/envelope-signer-page-renderer'),
async () => import('~/components/general/envelope-signing/envelope-signer-page-renderer'),
);
export const DocumentSigningPageViewV2 = () => {
@@ -71,7 +71,7 @@ export const DocumentSigningPageViewV2 = () => {
}, [recipientFieldsRemaining, selectedAssistantRecipientFields, currentEnvelopeItem]);
return (
<div className="dark:bg-background min-h-screen w-screen bg-gray-50">
<div className="min-h-screen w-screen bg-gray-50 dark:bg-background">
<SignFieldEmailDialog.Root />
<SignFieldTextDialog.Root />
<SignFieldNumberDialog.Root />
@@ -86,9 +86,9 @@ export const DocumentSigningPageViewV2 = () => {
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="embed--DocumentWidgetContainer bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex">
<div className="embed--DocumentWidgetContainer hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4 lg:flex">
<div className="px-4">
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
@@ -96,7 +96,7 @@ export const DocumentSigningPageViewV2 = () => {
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.otherwise(() => null)}
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Plural
value={recipientFieldsRemaining.length}
one="1 Field Remaining"
@@ -105,11 +105,11 @@ export const DocumentSigningPageViewV2 = () => {
</span>
</h3>
<div className="bg-muted relative my-4 h-[4px] rounded-md">
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="bg-documenso absolute inset-y-0 left-0"
className="absolute inset-y-0 left-0 bg-documenso"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
@@ -126,7 +126,7 @@ export const DocumentSigningPageViewV2 = () => {
{/* Quick Actions. */}
{!isDirectTemplate && (
<div className="embed--Actions space-y-3 px-4">
<h4 className="text-foreground text-sm font-semibold">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Actions</Trans>
</h4>
@@ -173,7 +173,7 @@ export const DocumentSigningPageViewV2 = () => {
<Button
variant="ghost"
size="sm"
className="hover:text-destructive w-full justify-start"
className="w-full justify-start hover:text-destructive"
>
<BanIcon className="mr-2 h-4 w-4" />
<Trans>Reject Document</Trans>
@@ -235,7 +235,7 @@ export const DocumentSigningPageViewV2 = () => {
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<p className="text-foreground text-sm">
<p className="text-sm text-foreground">
<Trans>No documents found</Trans>
</p>
</div>
@@ -250,7 +250,7 @@ export const DocumentSigningPageViewV2 = () => {
<a
href="https://documenso.com"
target="_blank"
className="bg-primary text-primary-foreground fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:block"
className="fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100 lg:block"
>
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
@@ -74,8 +74,8 @@ export function DocumentSigningRejectDialog({
});
toast({
title: 'Document rejected',
description: 'The document has been successfully rejected.',
title: t`Document rejected`,
description: t`The document has been successfully rejected.`,
duration: 5000,
});
@@ -88,8 +88,8 @@ export function DocumentSigningRejectDialog({
}
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while rejecting the document. Please try again.',
title: t`Error`,
description: t`An error occurred while rejecting the document. Please try again.`,
variant: 'destructive',
duration: 5000,
});
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { lazy, useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
@@ -21,12 +21,15 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
const EnvelopeGenericPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
);
export type DocumentCertificateQRViewProps = {
documentId: number;
@@ -120,7 +123,7 @@ export const DocumentCertificateQRView = ({
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1>
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
<div className="flex flex-col gap-0.5 text-sm text-muted-foreground">
<p>
<Trans>{recipientCount} recipients</Trans>
</p>
@@ -146,7 +149,7 @@ export const DocumentCertificateQRView = ({
</div>
<div className="mt-12 w-full">
<PDFViewer
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={token}
@@ -179,7 +182,7 @@ const DocumentCertificateQrV2 = ({
<div className="flex w-full flex-col justify-between gap-4 md:flex-row md:items-end">
<div className="space-y-1">
<h1 className="text-xl font-medium">{title}</h1>
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
<div className="flex flex-col gap-0.5 text-sm text-muted-foreground">
<p>
<Trans>{recipientCount} recipients</Trans>
</p>
@@ -27,7 +27,7 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -440,7 +440,7 @@ export const DocumentEditForm = ({
gradient
>
<CardContent className="p-2">
<PDFViewer
<PDFViewerLazy
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={undefined}
@@ -1,28 +1,31 @@
import { lazy, useEffect, useMemo } from 'react';
import { lazy, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { Link } from 'react-router';
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 { 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 {
TCheckboxFieldMeta,
TDateFieldMeta,
TDropdownFieldMeta,
TEmailFieldMeta,
TFieldMetaSchema,
TInitialsFieldMeta,
TNameFieldMeta,
TNumberFieldMeta,
TRadioFieldMeta,
TSignatureFieldMeta,
TTextFieldMeta,
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,
} 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';
@@ -31,6 +34,8 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
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';
@@ -41,13 +46,14 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
const EnvelopeEditorFieldsPageRenderer = lazy(
async () => import('./envelope-editor-fields-page-renderer'),
async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'),
);
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
@@ -65,11 +71,19 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
};
export const EnvelopeEditorFieldsPage = () => {
const [searchParams] = useSearchParams();
const team = useCurrentTeam();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { t } = useLingui();
const { _ } = useLingui();
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
@@ -94,6 +108,24 @@ export const EnvelopeEditorFieldsPage = () => {
}
};
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.
*/
@@ -106,6 +138,22 @@ export const EnvelopeEditorFieldsPage = () => {
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
}, []);
const onDetectClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
}
setIsAiFieldDialogOpen(true);
};
const onAiFeaturesEnabled = () => {
void revalidate().then(() => {
setIsAiEnableDialogOpen(false);
setIsAiFieldDialogOpen(true);
});
};
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
@@ -117,7 +165,7 @@ export const EnvelopeEditorFieldsPage = () => {
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
className="border-border bg-background mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border"
className="mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border border-border bg-background"
>
<div className="flex flex-col gap-1">
<AlertTitle>
@@ -143,11 +191,11 @@ export const EnvelopeEditorFieldsPage = () => {
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
<p className="text-foreground mt-1 text-sm">
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
<p className="mt-1 text-sm text-foreground">
<Trans>No documents found</Trans>
</p>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Please upload a document to continue</Trans>
</p>
</div>
@@ -157,10 +205,10 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4">
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-border bg-background py-4">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="text-foreground mb-2 text-sm font-semibold">
<h3 className="mb-2 text-sm font-semibold text-foreground">
<Trans>Selected Recipient</Trans>
</h3>
@@ -192,7 +240,7 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Add fields section. */}
<section className="px-4">
<h3 className="text-foreground mb-2 text-sm font-semibold">
<h3 className="mb-2 text-sm font-semibold text-foreground">
<Trans>Add Fields</Trans>
</h3>
@@ -200,6 +248,37 @@ export const EnvelopeEditorFieldsPage = () => {
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</section>
{/* Field details section. */}
@@ -208,9 +287,40 @@ export const EnvelopeEditorFieldsPage = () => {
<section>
<Separator className="my-4" />
<div className="[&_label]:text-foreground/70 px-4 [&_label]:text-xs">
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 text-sm font-semibold text-foreground">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-sm text-foreground">
<p>
<span className="min-w-12 text-muted-foreground">Pos X:&nbsp;</span>
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Pos Y:&nbsp;</span>
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Width:&nbsp;</span>
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">Height:&nbsp;</span>
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
<Separator className="my-4" />
</>
)}
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
<h3 className="text-sm font-semibold">
{t(FieldSettingsTypeTranslations[selectedField.type])}
{_(FieldSettingsTypeTranslations[selectedField.type])}
</h3>
{match(selectedField.type)
@@ -30,18 +30,11 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
export default function EnvelopeEditorHeader() {
const { t } = useLingui();
const {
envelope,
isDocument,
isTemplate,
updateEnvelope,
autosaveError,
relativePath,
editorFields,
} = useCurrentEnvelopeEditor();
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
useCurrentEnvelopeEditor();
return (
<nav className="bg-background border-border w-full border-b px-4 py-3 md:px-6">
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to="/">
@@ -147,10 +140,6 @@ export default function EnvelopeEditorHeader() {
{isDocument && (
<>
<EnvelopeDistributeDialog
envelope={{
...envelope,
fields: editorFields.localFields,
}}
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
@@ -23,7 +23,9 @@ import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
const EnvelopeGenericPageRenderer = lazy(
async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'),
);
// Todo: Envelopes - Dynamically import faker
export const EnvelopeEditorPreviewPage = () => {
@@ -232,11 +234,11 @@ export const EnvelopeEditorPreviewPage = () => {
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
<p className="text-foreground mt-1 text-sm">
<FileTextIcon className="h-10 w-10 text-muted-foreground" />
<p className="mt-1 text-sm text-foreground">
<Trans>No documents found</Trans>
</p>
<p className="text-muted-foreground mt-1 text-sm">
<p className="mt-1 text-sm text-muted-foreground">
<Trans>Please upload a document to continue</Trans>
</p>
</div>
@@ -8,12 +8,13 @@ import {
type SensorAPI,
} from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, 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';
@@ -22,10 +23,12 @@ 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';
@@ -60,15 +63,16 @@ 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: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -85,13 +89,59 @@ 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);
@@ -201,12 +251,13 @@ export const EnvelopeEditorRecipientForm = () => {
keyName: 'nativeId',
});
const emptySigners = useCallback(
() => form.getValues('signers').filter((signer) => signer.email === ''),
[form],
const emptySignerIndex = watchedSigners.findIndex(
(signer) =>
!signer.name &&
!signer.email &&
envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
);
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
);
@@ -244,6 +295,77 @@ 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];
@@ -306,8 +428,14 @@ export const EnvelopeEditorRecipientForm = () => {
index: number,
suggestion: RecipientAutoCompleteOption,
) => {
setValue(`signers.${index}.email`, suggestion.email);
setValue(`signers.${index}.name`, suggestion.name || '');
setValue(`signers.${index}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
};
const onDragEnd = useCallback(
@@ -460,21 +588,7 @@ export const EnvelopeEditorRecipientForm = () => {
return;
}
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,
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
if (!validatedFormValues.success) {
return;
@@ -543,6 +657,28 @@ 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"
@@ -570,7 +706,7 @@ export const EnvelopeEditorRecipientForm = () => {
<CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
{organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center">
<Checkbox
@@ -618,9 +754,7 @@ export const EnvelopeEditorRecipientForm = () => {
});
}
}}
disabled={
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
@@ -634,7 +768,7 @@ export const EnvelopeEditorRecipientForm = () => {
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
@@ -679,7 +813,7 @@ export const EnvelopeEditorRecipientForm = () => {
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
@@ -716,7 +850,7 @@ export const EnvelopeEditorRecipientForm = () => {
>
{signers.map((signer, index) => (
<Draggable
key={`${signer.id}-${signer.signingOrder}`}
key={`${signer.nativeId}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
@@ -732,7 +866,7 @@ export const EnvelopeEditorRecipientForm = () => {
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
})}
>
@@ -806,7 +940,7 @@ export const EnvelopeEditorRecipientForm = () => {
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
@@ -860,7 +994,7 @@ export const EnvelopeEditorRecipientForm = () => {
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
@@ -992,6 +1126,20 @@ 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>
);
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import type { DropResult } from '@hello-pangea/dnd';
import { msg } from '@lingui/core/macro';
import { msg, plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
@@ -226,7 +226,12 @@ export const EnvelopeEditorUploadPage = () => {
}
if (maximumEnvelopeItemCount <= localFiles.length) {
return msg`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`;
return msg({
message: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
});
}
return null;
@@ -240,7 +245,10 @@ export const EnvelopeEditorUploadPage = () => {
if (maxItemsReached) {
toast({
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # 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="dark:bg-background h-screen w-screen bg-gray-50">
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
<EnvelopeEditorHeader />
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4">
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
{/* Left section step selector. */}
<div className="px-4">
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
<Trans context="The step counter">
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span>
</h3>
<div className="bg-muted relative my-4 h-[4px] rounded-md">
<div className="relative my-4 h-[4px] rounded-md bg-muted">
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="bg-documenso absolute inset-y-0 left-0"
className="absolute inset-y-0 left-0 bg-documenso"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
@@ -219,7 +219,7 @@ export default function EnvelopeEditor() {
>
{t(step.title)}
</div>
<div className="text-muted-foreground text-xs">{t(step.description)}</div>
<div className="text-xs text-muted-foreground">{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-foreground text-sm font-semibold">
<h4 className="text-sm font-semibold text-foreground">
<Trans>Quick Actions</Trans>
</h4>
<EnvelopeEditorSettingsDialog
@@ -246,10 +246,6 @@ 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,5 +1,7 @@
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';
@@ -39,8 +41,15 @@ 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>
@@ -49,7 +58,7 @@ export const EnvelopeRecipientSelector = ({
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
@@ -59,16 +68,12 @@ export const EnvelopeRecipientSelector = ({
className,
)}
>
{selectedRecipient?.email && (
{selectedRecipient && (
<span className="flex-1 truncate text-left">
{selectedRecipient?.name} ({selectedRecipient?.email})
{getRecipientLabel(selectedRecipient)}
</span>
)}
{!selectedRecipient?.email && (
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
@@ -105,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
fields,
placeholder,
}: EnvelopeRecipientSelectorCommandProps) => {
const { t } = useLingui();
const { t, i18n } = useLingui();
const recipientsByRole = useCallback(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
@@ -154,6 +159,11 @@ export const EnvelopeRecipientSelectorCommand = ({
[fields, recipients],
);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
return (
<Command
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
@@ -162,21 +172,21 @@ export const EnvelopeRecipientSelectorCommand = ({
<CommandInput placeholder={placeholder} />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<span className="inline-block px-4 text-muted-foreground">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
>
<Trans>No recipients with this role</Trans>
</div>
@@ -205,18 +215,12 @@ export const EnvelopeRecipientSelectorCommand = ({
}}
>
<span
className={cn('text-foreground/70 truncate', {
className={cn('truncate text-foreground/70', {
'text-foreground/80': recipient.id === selectedRecipient?.id,
'opacity-50': isRecipientDisabled(recipient.id),
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
{getRecipientLabel(recipient)}
</span>
<div className="ml-auto flex items-center justify-center">
@@ -234,7 +238,7 @@ export const EnvelopeRecipientSelectorCommand = ({
<Info className="z-50 ml-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
This document has already been sent to this recipient. You can no longer
edit this recipient.
@@ -250,3 +254,22 @@ 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}`);
};
@@ -171,7 +171,7 @@ export default function EnvelopeSignerPageRenderer() {
const handleFieldGroupClick = (e: KonvaEventObject<Event>) => {
const currentTarget = e.currentTarget as Konva.Group;
const target = e.target;
const target = e.target as Konva.Shape;
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
@@ -57,28 +57,37 @@ export const EnvelopeSignerCompleteDialog = () => {
return;
}
if (nextField.envelopeItemId !== currentEnvelopeItem?.id) {
const isEnvelopeItemSwitch = nextField.envelopeItemId !== currentEnvelopeItem?.id;
if (isEnvelopeItemSwitch) {
setCurrentEnvelopeItem(nextField.envelopeItemId);
}
const fieldTooltip = document.querySelector(`#field-tooltip`);
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setShowPendingFieldTooltip(true);
setTimeout(
() => {
const fieldTooltip = document.querySelector(`#field-tooltip`);
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
},
isEnvelopeItemSwitch ? 150 : 50,
);
};
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 } : {}),
});
@@ -198,21 +207,30 @@ export const EnvelopeSignerCompleteDialog = () => {
}
};
const directTemplatePayload = useMemo(() => {
const recipientPayload = useMemo(() => {
if (!isDirectTemplate) {
return;
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 {
name: fullName,
email: email,
};
}, [email, fullName, isDirectTemplate]);
}, [email, fullName, isDirectTemplate, recipient.email, recipient.name, recipient.fields]);
return (
<DocumentSigningCompleteDialog
isSubmitting={isPending}
directTemplatePayload={directTemplatePayload}
recipientPayload={recipientPayload}
onSignatureComplete={
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
}
@@ -223,6 +241,7 @@ export const EnvelopeSignerCompleteDialog = () => {
allowDictateNextSigner={Boolean(
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
)}
disableNameInput={!isDirectTemplate && recipient.name !== ''}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
@@ -1,5 +1,6 @@
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 { EnvelopeType } from '@prisma/client';
@@ -115,7 +116,9 @@ export const EnvelopeDropZoneWrapper = ({
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
await navigate(`${pathPrefix}/${id}/edit`);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
} catch (err) {
const error = AppError.parseError(err);
@@ -153,7 +156,10 @@ export const EnvelopeDropZoneWrapper = ({
if (maxItemsReached) {
toast({
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
duration: 5000,
variant: 'destructive',
});
@@ -220,9 +226,9 @@ export const EnvelopeDropZoneWrapper = ({
{children}
{isDragActive && (
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="fixed left-0 top-0 z-[9999] h-full w-full bg-muted/60 backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-foreground text-2xl font-semibold">
<h2 className="text-2xl font-semibold text-foreground">
{type === EnvelopeType.DOCUMENT ? (
<Trans>Upload Document</Trans>
) : (
@@ -230,7 +236,7 @@ export const EnvelopeDropZoneWrapper = ({
)}
</h2>
<p className="text-muted-foreground text-md mt-4">
<p className="text-md mt-4 text-muted-foreground">
<Trans>Drag and drop your PDF file here</Trans>
</p>
@@ -247,7 +253,7 @@ export const EnvelopeDropZoneWrapper = ({
team?.id === undefined &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/80 mt-4 text-sm">
<p className="mt-4 text-sm text-muted-foreground/80">
<Trans>
{remaining.documents} of {quota.documents} documents remaining this month.
</Trans>
@@ -258,10 +264,10 @@ export const EnvelopeDropZoneWrapper = ({
)}
{isLoading && (
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
<div className="absolute inset-0 z-50 bg-muted/30 backdrop-blur-[2px]">
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium">
<Loader className="h-12 w-12 animate-spin text-primary" />
<p className="mt-8 font-medium text-foreground">
<Trans>Uploading</Trans>
</p>
</div>
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { msg, plural } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
@@ -108,7 +108,9 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
await navigate(`${pathPrefix}/${id}/edit`);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
toast({
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
@@ -153,7 +155,10 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
if (maxItemsReached) {
toast({
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
duration: 5000,
variant: 'destructive',
});
@@ -83,8 +83,8 @@ export const StackAvatarsWithTooltip = ({
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -107,8 +107,8 @@ export const StackAvatarsWithTooltip = ({
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>

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