Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e20561e91 | |||
| a2ec5f0fa1 | |||
| de8d13a4c1 | |||
| 495d61a11d | |||
| 90fdba8000 | |||
| aa1cada79b | |||
| 790b385849 | |||
| baa2c51123 | |||
| 1e585e06e6 | |||
| 5624484631 | |||
| 810e00da03 | |||
| eeeee2fa0e | |||
| c50a31a503 | |||
| 7360709795 | |||
| df678d7d69 | |||
| 6739242554 | |||
| a5e5eecf8b | |||
| b0248c20eb | |||
| f129968968 | |||
| c5c87e3fd1 | |||
| 24a74c7b57 | |||
| f0a5a7e816 | |||
| 8462cd13fd | |||
| 576846de32 | |||
| 06071ea035 | |||
| b45a2691ba | |||
| f31cc575d0 | |||
| 05d7015ef0 | |||
| 2ca5d6cfaa | |||
| 04814ca14e | |||
| dd1dccdb6a | |||
| df4316ac5c | |||
| 02f1264eea | |||
| 928edb8645 | |||
| 54b0e4964e | |||
| 68e6ccdd19 | |||
| 09ab7e9a09 | |||
| 3bb0777914 | |||
| 4d6389e901 | |||
| 51e3d5030d | |||
| 0cebdec637 | |||
| 43486d8448 | |||
| 4d3d1b8d14 | |||
| 0387f3c20a | |||
| c5032d0c43 | |||
| 3bd34964cd | |||
| fe93b11a2c | |||
| 7638faf27b | |||
| 8fca029d96 | |||
| bac2bf11f4 | |||
| d93b2a70a7 | |||
| 5da915da38 | |||
| dcaecf1fc5 | |||
| f70b76d8b8 | |||
| 93137c6396 | |||
| d058b7c705 | |||
| b51f562224 | |||
| f80aa4bf72 | |||
| 9238f759a6 | |||
| 74ad6af47d | |||
| 18902ed59d | |||
| 3f70082146 | |||
| 31ba6d5f00 | |||
| c4f89a87a2 | |||
| 89d6dd5b0e |
@@ -1,14 +1,19 @@
|
|||||||
name: Playwright Tests
|
name: Playwright Tests
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['main', 'feat/rr7']
|
branches: ['main']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ['main']
|
branches: ['main']
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
e2e_tests:
|
e2e_tests:
|
||||||
name: 'E2E Tests'
|
name: 'E2E Tests'
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
runs-on: warp-ubuntu-2204-x64-16x
|
runs-on: warp-ubuntu-2204-x64-8x
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -28,9 +33,6 @@ jobs:
|
|||||||
- name: Seed the database
|
- name: Seed the database
|
||||||
run: npm run prisma:seed
|
run: npm run prisma:seed
|
||||||
|
|
||||||
- name: Build app
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Install playwright browsers
|
- name: Install playwright browsers
|
||||||
run: npx playwright install --with-deps
|
run: npx playwright install --with-deps
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: test-results
|
name: test-results
|
||||||
path: 'packages/app-tests/**/test-results/*'
|
path: 'packages/app-tests/**/test-results/*'
|
||||||
retention-days: 30
|
retention-days: 7
|
||||||
env:
|
env:
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|||||||
@@ -65,6 +65,47 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
|
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
|
||||||
|
|
||||||
|
- name: Build the chromium docker image
|
||||||
|
env:
|
||||||
|
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
|
||||||
|
run: |
|
||||||
|
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||||
|
GIT_SHA="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
-f ./docker/Dockerfile.chromium \
|
||||||
|
--progress=plain \
|
||||||
|
--build-arg TAG="$GIT_SHA" \
|
||||||
|
-t "documenso/documenso-$BUILD_PLATFORM:latest-chromium" \
|
||||||
|
-t "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" \
|
||||||
|
-t "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \
|
||||||
|
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest-chromium" \
|
||||||
|
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" \
|
||||||
|
-t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push the chromium docker image to DockerHub
|
||||||
|
env:
|
||||||
|
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
|
||||||
|
run: |
|
||||||
|
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||||
|
GIT_SHA="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
docker push "documenso/documenso-$BUILD_PLATFORM:latest-chromium"
|
||||||
|
docker push "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium"
|
||||||
|
docker push "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \
|
||||||
|
|
||||||
|
- name: Push the chromium docker image to GitHub Container Registry
|
||||||
|
env:
|
||||||
|
BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }}
|
||||||
|
run: |
|
||||||
|
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||||
|
GIT_SHA="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest-chromium"
|
||||||
|
docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium"
|
||||||
|
docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium"
|
||||||
|
|
||||||
create_and_publish_manifest:
|
create_and_publish_manifest:
|
||||||
name: Create and publish manifest
|
name: Create and publish manifest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -125,6 +166,43 @@ jobs:
|
|||||||
docker manifest push documenso/documenso:$GIT_SHA
|
docker manifest push documenso/documenso:$GIT_SHA
|
||||||
docker manifest push documenso/documenso:$APP_VERSION
|
docker manifest push documenso/documenso:$APP_VERSION
|
||||||
|
|
||||||
|
- name: Create and push DockerHub chromium manifest
|
||||||
|
run: |
|
||||||
|
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||||
|
GIT_SHA="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
# Check if the version is stable (no rc or beta in the version)
|
||||||
|
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
docker manifest create \
|
||||||
|
documenso/documenso:latest-chromium \
|
||||||
|
--amend documenso/documenso-amd64:latest-chromium \
|
||||||
|
--amend documenso/documenso-arm64:latest-chromium
|
||||||
|
|
||||||
|
docker manifest push documenso/documenso:latest-chromium
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||||
|
docker manifest create \
|
||||||
|
documenso/documenso:rc-chromium \
|
||||||
|
--amend documenso/documenso-amd64:rc-chromium \
|
||||||
|
--amend documenso/documenso-arm64:rc-chromium
|
||||||
|
|
||||||
|
docker manifest push documenso/documenso:rc-chromium
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker manifest create \
|
||||||
|
documenso/documenso:$GIT_SHA-chromium \
|
||||||
|
--amend documenso/documenso-amd64:$GIT_SHA-chromium \
|
||||||
|
--amend documenso/documenso-arm64:$GIT_SHA-chromium
|
||||||
|
|
||||||
|
docker manifest create \
|
||||||
|
documenso/documenso:$APP_VERSION-chromium \
|
||||||
|
--amend documenso/documenso-amd64:$APP_VERSION-chromium \
|
||||||
|
--amend documenso/documenso-arm64:$APP_VERSION-chromium
|
||||||
|
|
||||||
|
docker manifest push documenso/documenso:$GIT_SHA-chromium
|
||||||
|
docker manifest push documenso/documenso:$APP_VERSION-chromium
|
||||||
|
|
||||||
- name: Create and push Github Container Registry manifest
|
- name: Create and push Github Container Registry manifest
|
||||||
run: |
|
run: |
|
||||||
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||||
@@ -161,3 +239,40 @@ jobs:
|
|||||||
|
|
||||||
docker manifest push ghcr.io/documenso/documenso:$GIT_SHA
|
docker manifest push ghcr.io/documenso/documenso:$GIT_SHA
|
||||||
docker manifest push ghcr.io/documenso/documenso:$APP_VERSION
|
docker manifest push ghcr.io/documenso/documenso:$APP_VERSION
|
||||||
|
|
||||||
|
- name: Create and push Github Container Registry chromium manifest
|
||||||
|
run: |
|
||||||
|
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
|
||||||
|
GIT_SHA="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
# Check if the version is stable (no rc or beta in the version)
|
||||||
|
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
docker manifest create \
|
||||||
|
ghcr.io/documenso/documenso:latest-chromium \
|
||||||
|
--amend ghcr.io/documenso/documenso-amd64:latest-chromium \
|
||||||
|
--amend ghcr.io/documenso/documenso-arm64:latest-chromium
|
||||||
|
|
||||||
|
docker manifest push ghcr.io/documenso/documenso:latest-chromium
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||||
|
docker manifest create \
|
||||||
|
ghcr.io/documenso/documenso:rc-chromium \
|
||||||
|
--amend ghcr.io/documenso/documenso-amd64:rc-chromium \
|
||||||
|
--amend ghcr.io/documenso/documenso-arm64:rc-chromium
|
||||||
|
|
||||||
|
docker manifest push ghcr.io/documenso/documenso:rc-chromium
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker manifest create \
|
||||||
|
ghcr.io/documenso/documenso:$GIT_SHA-chromium \
|
||||||
|
--amend ghcr.io/documenso/documenso-amd64:$GIT_SHA-chromium \
|
||||||
|
--amend ghcr.io/documenso/documenso-arm64:$GIT_SHA-chromium
|
||||||
|
|
||||||
|
docker manifest create \
|
||||||
|
ghcr.io/documenso/documenso:$APP_VERSION-chromium \
|
||||||
|
--amend ghcr.io/documenso/documenso-amd64:$APP_VERSION-chromium \
|
||||||
|
--amend ghcr.io/documenso/documenso-arm64:$APP_VERSION-chromium
|
||||||
|
|
||||||
|
docker manifest push ghcr.io/documenso/documenso:$GIT_SHA-chromium
|
||||||
|
docker manifest push ghcr.io/documenso/documenso:$APP_VERSION-chromium
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ jobs:
|
|||||||
environment: Translations
|
environment: Translations
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -26,12 +27,54 @@ jobs:
|
|||||||
- name: Extract translations
|
- name: Extract translations
|
||||||
run: npm run translate:extract
|
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: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BRANCH="chore/extract-translations"
|
||||||
|
|
||||||
git config --global user.name 'github-actions'
|
git config --global user.name 'github-actions'
|
||||||
git config --global user.email 'github-actions@documenso.com'
|
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 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
|
- name: Compile translations
|
||||||
id: compile_translations
|
id: compile_translations
|
||||||
|
|||||||
@@ -17,5 +17,6 @@
|
|||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
}
|
},
|
||||||
|
"prisma.pinToPrisma6": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"next": "^15.5.7",
|
"next": "15.5.9",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
"nextra": "^3",
|
"nextra": "^3",
|
||||||
"nextra-theme-docs": "^3",
|
"nextra-theme-docs": "^3",
|
||||||
@@ -29,4 +29,4 @@
|
|||||||
"pagefind": "^1.2.0",
|
"pagefind": "^1.2.0",
|
||||||
"typescript": "5.6.2"
|
"typescript": "5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,6 @@ You can access the following services:
|
|||||||
- Main application - http://localhost:3000
|
- Main application - http://localhost:3000
|
||||||
- Incoming Mail Access - http://localhost:9000
|
- Incoming Mail Access - http://localhost:9000
|
||||||
- Database Connection Details:
|
- Database Connection Details:
|
||||||
- Port: 54320
|
- Port: 54320
|
||||||
- Connection: Use your favourite database client to connect to the database.
|
- Connection: Use your favorite database client to connect to the database.
|
||||||
- S3 Storage Dashboard - http://localhost:9001
|
- S3 Storage Dashboard - http://localhost:9001
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Rate Limits
|
||||||
|
description: Learn about the rate limits for the Documenso Public API.
|
||||||
|
---
|
||||||
|
|
||||||
import { Callout } from 'nextra/components';
|
import { Callout } from 'nextra/components';
|
||||||
|
|
||||||
# Rate Limits
|
# Rate Limits
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export default {
|
|||||||
'how-to': 'How To',
|
'how-to': 'How To',
|
||||||
'setting-up-oauth-providers': 'Setting up OAuth Providers',
|
'setting-up-oauth-providers': 'Setting up OAuth Providers',
|
||||||
telemetry: 'Telemetry',
|
telemetry: 'Telemetry',
|
||||||
|
'ai-features': 'AI Recipient & Field Detection',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
title: AI Recipient & Field Detection (Self-hosting)
|
||||||
|
description: Configure Google Vertex AI so Documenso can detect recipients and fields automatically.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Callout, Steps } from 'nextra/components';
|
||||||
|
|
||||||
|
# AI Recipient & Field Detection (Self-hosting)
|
||||||
|
|
||||||
|
This guide covers how to enable the AI recipient and field detection features when you self-host Documenso.
|
||||||
|
|
||||||
|
## What this enables
|
||||||
|
|
||||||
|
- Detect recipients from uploaded PDFs (roles, names, emails when present).
|
||||||
|
- Detect and place fields (signature, initials, name, email, date, text, number, radio, checkbox) onto draft envelopes.
|
||||||
|
- Built-in rate limits (3 requests per minute per IP) to prevent abuse.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- A Google Cloud project with the **Vertex AI API** enabled and billing active.
|
||||||
|
- A **Vertex AI Express API key** with access to Gemini models (create via the [Vertex AI Express flow](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview) and manage keys in [API keys](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys)).
|
||||||
|
- Documenso version that includes the AI detection feature and the corresponding database migration.
|
||||||
|
|
||||||
|
## Configure environment variables
|
||||||
|
|
||||||
|
Add these variables to your deployment `.env` (or secret manager):
|
||||||
|
|
||||||
|
```
|
||||||
|
GOOGLE_VERTEX_PROJECT_ID="<your-gcp-project-id>"
|
||||||
|
GOOGLE_VERTEX_API_KEY="<your-vertex-api-key>"
|
||||||
|
# Optional, defaults to "global"
|
||||||
|
GOOGLE_VERTEX_LOCATION="global"
|
||||||
|
```
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
Use a region close to your users if you need data residency considerations (e.g. `europe-west1`).
|
||||||
|
If you omit the location, Documenso uses `global`. Not all models are available in every region;
|
||||||
|
if a model is unavailable, switch to a supported region.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Deploy with the published container
|
||||||
|
|
||||||
|
- Use the official Documenso image (DockerHub or GHCR) and supply the Vertex env vars above.
|
||||||
|
- Ensure migrations run on startup (the container runs `prisma migrate deploy` in production mode).
|
||||||
|
- Restart the container after adding or changing Vertex env vars.
|
||||||
|
|
||||||
|
## Enable the feature in Documenso
|
||||||
|
|
||||||
|
Once the service is running with the Vertex env vars:
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
### Organisation settings
|
||||||
|
|
||||||
|
Go to **Settings → Document Preferences → AI Features** and set to **Enabled**. Teams that inherit organisation defaults will pick this up.
|
||||||
|
|
||||||
|
### Team settings
|
||||||
|
|
||||||
|
If a team overrides organisation defaults, go to **Team Settings → Document Preferences → AI Features** and choose **Enabled** (or **Inherit** to follow the organisation).
|
||||||
|
|
||||||
|
### Verify in the editor
|
||||||
|
|
||||||
|
Open a draft envelope. In **Recipients**, you should see the sparkle button for AI detection. In **Fields**, you should see **Detect with AI** available.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Too many requests**: Wait a minute or two and retry (rate limit is 3/min per IP).
|
||||||
|
- **AI options hidden**: Ensure the env vars are set, the server was restarted after setting them, and `aiFeaturesEnabled` is enabled at organisation/team level.
|
||||||
|
- **Detection fails immediately**: Confirm the Vertex API key is valid and the project has Vertex AI enabled. Check server logs for status codes from Vertex.
|
||||||
|
|
||||||
|
If issues persist, recheck env vars, restart the service, and confirm the Prisma migration was applied.
|
||||||
@@ -119,6 +119,8 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
|||||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
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
|
### Set Up Your Signing Certificate
|
||||||
|
|
||||||
<Callout type="warning">
|
<Callout type="warning">
|
||||||
@@ -267,58 +269,63 @@ 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.
|
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 |
|
For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
|
||||||
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
||||||
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
| Variable | Description |
|
||||||
| `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). |
|
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
||||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
|
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
||||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
|
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
||||||
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
|
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
|
||||||
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
|
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
|
||||||
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
|
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
|
||||||
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
|
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
|
||||||
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
|
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
|
||||||
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
|
||||||
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
|
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
|
||||||
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
|
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
||||||
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
|
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
|
||||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
|
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
|
||||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
|
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
|
||||||
| `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_TRANSPORT` | The signing transport to use. Available options: local (default) |
|
||||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
|
||||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
|
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
|
||||||
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
|
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
||||||
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
|
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
|
||||||
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
|
||||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
|
||||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||||
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||||
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||||
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
||||||
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
||||||
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
||||||
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
||||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
||||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
||||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
|
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
|
||||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||||
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
|
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||||
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
|
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
|
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
|
||||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels 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_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
|
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain 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_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
|
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||||
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
|
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
|
| `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
|
## Telemetry
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Telemetry
|
||||||
|
description: Learn about the telemetry data that Documenso collects from self-hosted instances.
|
||||||
|
---
|
||||||
|
|
||||||
# Telemetry
|
# 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.
|
Documenso collects anonymous telemetry data from self-hosted instances to help us understand how the software is being used and make improvements to the product. This telemetry is enabled by default, but you can easily disable it if you prefer.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: Learn how to use webhooks to receive real-time notifications about
|
|||||||
|
|
||||||
# Webhooks
|
# Webhooks
|
||||||
|
|
||||||
Webhooks are HTTP callbacks triggered by specific events. When the user subscribes to a specific event, and that event occurs, the webhook makes an HTTP request to the URL provided by the user. The request can be a simple notification or carry a payload with more information about the event.
|
Webhooks are HTTP callbacks triggered by specific events. When you subscribe to a specific event and that event occurs, the webhook makes an HTTP request to the URL you provide. The request can be a simple notification or carry a payload with more information about the event.
|
||||||
|
|
||||||
Some of the common use cases for webhooks include:
|
Some of the common use cases for webhooks include:
|
||||||
|
|
||||||
@@ -25,13 +25,13 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
|
|||||||
|
|
||||||
## Create a webhook subscription
|
## Create a webhook subscription
|
||||||
|
|
||||||
You can create a webhook subscription from the user settings page. Click on your avatar in the top right corner of the dashboard and select "**[User settings](https://app.documenso.com/settings)**" from the dropdown menu.
|
You can create a webhook subscription from the team settings page. Click your avatar in the top right corner of the dashboard and select "Team settings" from the dropdown menu.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Then, navigate to the "**[Webhooks](https://app.documenso.com/settings/webhooks)**" tab, where you can see a list of your existing webhooks and create new ones.
|
Then, navigate to the "Webhooks" tab, which takes you to the webhooks main page.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Clicking on the "**Create Webhook**" button opens a modal to create a new webhook subscription.
|
Clicking on the "**Create Webhook**" button opens a modal to create a new webhook subscription.
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ To create a new webhook subscription, you need to provide the following informat
|
|||||||
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
|
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
|
||||||
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
|
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
After you have filled in the required information, click on the "**Create Webhook**" button to save your subscription.
|
After you have filled in the required information, click on the "**Create Webhook**" button to save your subscription.
|
||||||
|
|
||||||
@@ -49,7 +49,22 @@ The screenshot below illustrates a newly created webhook subscription.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
You can edit or delete your webhook subscriptions by clicking the "**Edit**" or "**Delete**" buttons next to the webhook.
|
You can edit, view the logs, or delete your webhook subscriptions by clicking the three dots (...) under the "Action" column. You can also access the webhook logs by clicking on the webhook subscription directly.
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
This page shows the details of the webhook call such as:
|
||||||
|
|
||||||
|
- status
|
||||||
|
- event
|
||||||
|
- date when the webhook was sent
|
||||||
|
- response code
|
||||||
|
- request body
|
||||||
|
- response body and headers
|
||||||
|
|
||||||
## Webhook fields
|
## Webhook fields
|
||||||
|
|
||||||
@@ -619,18 +634,26 @@ Example payload for the `document.rejected` event:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Webhook Events Testing
|
## Webhook events testing
|
||||||
|
|
||||||
You can trigger test webhook events to test the webhook functionality. To trigger a test webhook, navigate to the [Webhooks page](/developers/webhooks) and click on the "Test Webhook" button.
|
You can trigger test webhook events to test the webhook functionality. To do so, navigate to the webhook subscription details page and click the "Test" button.
|
||||||
|
|
||||||

|

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

|

|
||||||
|
|
||||||
Choose the appropriate event and click "Send Test Webhook." You’ll shortly receive a test payload from Documenso with sample data.
|
Choose the event you want to test and click "Send". You’ll then receive a test payload from Documenso with sample data.
|
||||||
|
|
||||||
|
## Webhook events resending
|
||||||
|
|
||||||
|
To resend a webhook call, you need to navigate to the webhook call page and click the "Resend" button.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This will send the webhook event to the webhook URL again.
|
||||||
|
|
||||||
## Availability
|
## Availability
|
||||||
|
|
||||||
Webhooks are available to individual users and teams.
|
Webhooks are available to teams only.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Signature Levels
|
||||||
|
description: Learn about the different signature levels for Documenso.
|
||||||
|
---
|
||||||
|
|
||||||
import { Callout } from 'nextra/components';
|
import { Callout } from 'nextra/components';
|
||||||
|
|
||||||
# Signature Levels
|
# Signature Levels
|
||||||
@@ -26,20 +31,20 @@ ensures the legal validity and enforceability of electronic signatures and recor
|
|||||||
|
|
||||||
### Main Requirements
|
### Main Requirements
|
||||||
|
|
||||||
- [x] Intent to Sign: "Parties must demonstrate their intent to sign [..]"
|
- [x] **Intent to Sign**: "Parties must demonstrate their intent to sign [..]"
|
||||||
- [x] Consent: "The ESIGN Act requires that all parties involved in a transaction consent to the use of electronic signatures and records [..]"
|
- [x] **Consent**: "The ESIGN Act requires that all parties involved in a transaction consent to the use of electronic signatures and records [..]"
|
||||||
- [x] Consumer Disclosures: Before obtaining their consent, financial institutions must provide the consumer a clear and conspicuous statement informing the consumer [..]
|
- [x] **Consumer Disclosures**: Before obtaining their consent, financial institutions must provide the consumer a clear and conspicuous statement informing the consumer [..]
|
||||||
- [x] Record Retention: Electronic Records must be maintained for later access by signers.
|
- [x] **Record Retention**: Electronic Records must be maintained for later access by signers.
|
||||||
- [x] Security: The ESIGN Act does not mandate specific security measures, but it does require that parties take reasonable steps to ensure the security and integrity of electronic signatures and records. This may include implementing encryption, access controls, and authentication measures.
|
- [x] **Security**: The ESIGN Act does not mandate specific security measures, but it does require that parties take reasonable steps to ensure the security and integrity of electronic signatures and records. This may include implementing encryption, access controls, and authentication measures.
|
||||||
|
|
||||||
## UETA (Uniform Electronic Transactions Act)
|
## UETA (Uniform Electronic Transactions Act)
|
||||||
|
|
||||||
<Callout type="info" emoji="✅">
|
<Callout type="info" emoji="✅">
|
||||||
Status: Compliant
|
Status: Compliant
|
||||||
</Callout>
|
</Callout>
|
||||||
The Uniform Electronic Transactions Act is a law that provides a legal framework for the use of electronic
|
The Uniform Electronic Transactions Act is a law that provides a legal framework for the use of
|
||||||
signatures and records in electronic transactions, ensuring they have the same validity and enforceability
|
electronic signatures and records in electronic transactions, ensuring they have the same validity
|
||||||
as paper documents and handwritten signatures.
|
and enforceability as paper documents and handwritten signatures.
|
||||||
|
|
||||||
### Main Requirements
|
### Main Requirements
|
||||||
|
|
||||||
@@ -50,9 +55,9 @@ _See [ESIGN](/users/compliance/signature-levels#-esign-electronic-signatures-in-
|
|||||||
<Callout type="info" emoji="✅">
|
<Callout type="info" emoji="✅">
|
||||||
Status: Compliant for Level 1 - SES (Simple Electronic Signatures)
|
Status: Compliant for Level 1 - SES (Simple Electronic Signatures)
|
||||||
</Callout>
|
</Callout>
|
||||||
eIDAS (Electronic Identification, Authentication and Trust Services) is an EU regulation that standardizes
|
eIDAS (Electronic Identification, Authentication and Trust Services) is an EU regulation that
|
||||||
electronic identification and trust services for secure and seamless electronic transactions across European
|
standardizes electronic identification and trust services for secure and seamless electronic
|
||||||
member states.
|
transactions across European member states.
|
||||||
|
|
||||||
### Level 1 - SES (Simple Electronic Signatures)
|
### Level 1 - SES (Simple Electronic Signatures)
|
||||||
|
|
||||||
@@ -69,8 +74,8 @@ eIDAS SES (Simple Electronic Signature) is a basic electronic signature with min
|
|||||||
Status: [Planned](https://github.com/documenso/backlog/issues/9) via third party until [Let's
|
Status: [Planned](https://github.com/documenso/backlog/issues/9) via third party until [Let's
|
||||||
Sign](https://github.com/documenso/backlog/issues/21) is realized.
|
Sign](https://github.com/documenso/backlog/issues/21) is realized.
|
||||||
</Callout>
|
</Callout>
|
||||||
eIDAS AES (Advanced Electronic Signature) provides a higher level of security with unique identification
|
eIDAS AES (Advanced Electronic Signature) provides a higher level of security with unique
|
||||||
of the signer and data integrity.
|
identification of the signer and data integrity.
|
||||||
|
|
||||||
### Main Requirements
|
### Main Requirements
|
||||||
|
|
||||||
@@ -85,8 +90,8 @@ of the signer and data integrity.
|
|||||||
Status: [Planned](https://github.com/documenso/backlog/issues/32) via third party until [Let's
|
Status: [Planned](https://github.com/documenso/backlog/issues/32) via third party until [Let's
|
||||||
Sign](https://github.com/documenso/backlog/issues/21) is realized.
|
Sign](https://github.com/documenso/backlog/issues/21) is realized.
|
||||||
</Callout>
|
</Callout>
|
||||||
eIDAS QES (Qualified Electronic Signature) is the highest security level, legally equivalent to a handwritten
|
eIDAS QES (Qualified Electronic Signature) is the highest security level, legally equivalent to a
|
||||||
signature within the EU.
|
handwritten signature within the EU.
|
||||||
|
|
||||||
### Main Requirements
|
### Main Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Standards and Regulations
|
||||||
|
description: Learn about the different standards and regulations for Documenso.
|
||||||
|
---
|
||||||
|
|
||||||
import { Callout } from 'nextra/components';
|
import { Callout } from 'nextra/components';
|
||||||
|
|
||||||
## 21 CFR Part 11
|
## 21 CFR Part 11
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export default {
|
|||||||
'document-visibility': 'Document Visibility',
|
'document-visibility': 'Document Visibility',
|
||||||
fields: 'Document Fields',
|
fields: 'Document Fields',
|
||||||
'email-preferences': 'Email Preferences',
|
'email-preferences': 'Email Preferences',
|
||||||
|
'ai-detection': 'AI Recipient & Field Detection',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
title: AI Recipient & Field Detection
|
||||||
|
description: Use Documenso’s AI helpers to detect recipients and fields in draft documents.
|
||||||
|
---
|
||||||
|
|
||||||
|
# AI Recipient & Field Detection
|
||||||
|
|
||||||
|
Documenso can suggest recipients and place fields automatically using Google Vertex AI (Gemini). The feature is optional and only available when your organisation or team has **AI Features** enabled. Documents are processed securely and providers do not retain your data for training.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- AI Features must be enabled in **Document Preferences** for your organisation or team.
|
||||||
|
- The envelope must be in **Draft** status.
|
||||||
|
- Helpful rate limits are in place (up to 3 detection requests per minute per IP) to prevent abuse. If you see a “too many requests” message, wait a minute or two and try again.
|
||||||
|
|
||||||
|
### Enable AI features
|
||||||
|
|
||||||
|
1. **Organisation settings**:
|
||||||
|
|
||||||
|
Settings → Document Preferences → **AI Features** → Enabled.
|
||||||
|
|
||||||
|
_This applies to teams that inherit organisation defaults._
|
||||||
|
|
||||||
|
2. **Team settings**:
|
||||||
|
|
||||||
|
Team Settings → Document Preferences → **AI Features** → choose Enabled, Disabled, or Inherit.
|
||||||
|
|
||||||
|
## Detect recipients
|
||||||
|
|
||||||
|
Use this to identify who needs to sign or approve.
|
||||||
|
|
||||||
|
1. Open a draft document/template and go to the **Recipients** panel.
|
||||||
|
2. Select the **sparkle** button to start detection. If AI is enabled, uploads launched from the dashboard will open the detector automatically.
|
||||||
|
|
||||||
|

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

|
||||||
|

|
||||||
|
|
||||||
|
3. Watch the progress indicators; they update per page and total fields found.
|
||||||
|
4. Review the summary and choose **Add fields** to place them in the editor.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Works only for draft envelopes and teams with AI features enabled.
|
||||||
|
- Existing fields are masked during detection to avoid duplicates.
|
||||||
|
- Fields are assigned to recipients based on nearby labels and your context message; you can edit them after adding.
|
||||||
|
|
||||||
|
## Best practices
|
||||||
|
|
||||||
|
- Keep labels near the intended fields (e.g., “Tenant signature”, “Buyer email”).
|
||||||
|
- Provide short context when roles are ambiguous.
|
||||||
|
- Always review suggestions before sending; AI assists but does not replace final checks.
|
||||||
@@ -178,7 +178,7 @@ The dropdown/select field collects a single choice from a list of options.
|
|||||||
|
|
||||||
Place the dropdown/select field on the document where you want the signer to select a choice. The dropdown/select field comes with additional settings that can be configured.
|
Place the dropdown/select field on the document where you want the signer to select a choice. The dropdown/select field comes with additional settings that can be configured.
|
||||||
|
|
||||||
{/*  */}
|

|
||||||
|
|
||||||
The dropdown/select field settings include:
|
The dropdown/select field settings include:
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Email Domains
|
||||||
|
description: Learn how to create and manage email domains in Documenso.
|
||||||
|
---
|
||||||
|
|
||||||
import { Callout, Steps } from 'nextra/components';
|
import { Callout, Steps } from 'nextra/components';
|
||||||
|
|
||||||
# Email Domains
|
# Email Domains
|
||||||
|
|||||||
@@ -7,28 +7,28 @@ import { Callout } from 'nextra/components';
|
|||||||
|
|
||||||
# Fair Use Policy
|
# Fair Use Policy
|
||||||
|
|
||||||
### Why
|
We offer our plans without any limits on volume because we want our users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using.
|
||||||
|
|
||||||
We offer our plans without any limits on volume because we want our users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using. This is why the individual plan and the team plan do not include a limit on signing or API volume. If you are a customer of these [plans](https://documen.so/pricing), we ask you to abide by this fair use policy:
|
This is why the individual plan and the team plan do not include a limit on signing or API volume. If you are a customer of these [plans](https://documen.so/pricing), we ask you to abide by this fair use policy:
|
||||||
|
|
||||||
### Spirit of the Plan
|
### Spirit of the Plan
|
||||||
|
|
||||||
> Use the limitless accounts as much as you like (they are meant to offer a lot) while respecting the spirit and intended scope of the account.
|
Use the limitless accounts as much as you like (they are meant to offer a lot) while respecting the spirit and intended scope of the account.
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
What happens if I violate this policy? We will ask you to upgrade to a fitting plan or custom
|
What happens if I violate this policy? We will ask you to upgrade to a fitting plan or custom
|
||||||
pricing. We won’t block your account without reaching out. [Message
|
pricing. We won’t block your account without reaching out. You can [message
|
||||||
us](mailto:support@documenso.com) for questions. It's probably fine, though.
|
us](mailto:support@documenso.com) for questions.
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
### DO
|
### DO
|
||||||
|
|
||||||
- Sign as many documents with the individual plan for your single business or organization you are part of
|
- Sign as many documents as you need with the individual plan for your single business or organization you are part of
|
||||||
- Use the API and Zapier to automate all your signing to sign as much as possible
|
- Use the API and automation tools to automate all your signing workflows
|
||||||
- Experiment with the plans and integrations, testing what you want to build: When in doubt, do it. Especially if you are just starting.
|
- Experiment with the plans and integrations, testing what you want to build
|
||||||
|
|
||||||
### DON'T
|
### DON'T
|
||||||
|
|
||||||
- Use the individual account's API to power a platform
|
- Use the individual account's API to power a platform
|
||||||
- Run a huge company, signing thousands of documents per day on a two-user team plan using the API
|
- Run a huge company, signing thousands of documents per day on a two-user team plan using the API
|
||||||
- Let this policy make you overthink. If you are a paying customer, we want you to win, and it's probably fine
|
- Let this policy make you overthink. If you are a paying customer, we want you to win
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import { Callout, Steps } from 'nextra/components';
|
|||||||
<Steps>
|
<Steps>
|
||||||
### Pick a Plan
|
### Pick a Plan
|
||||||
|
|
||||||
The first step to start using Documenso is to pick a plan and create an account. At the moment of writing this guide, we have 3 plans available: Free, Individual, Teams and Platform.
|
The first step to start using Documenso is to pick a plan and create an account. At the moment of writing this guide, we have 4 plans available:
|
||||||
|
|
||||||
|
- Free
|
||||||
|
- Individual
|
||||||
|
- Teams
|
||||||
|
- Platform
|
||||||
|
|
||||||
Explore each plan's features and choose the one that best suits your needs. The [pricing page](https://documen.so/pricing) has more information about the plans.
|
Explore each plan's features and choose the one that best suits your needs. The [pricing page](https://documen.so/pricing) has more information about the plans.
|
||||||
|
|
||||||
@@ -24,7 +29,7 @@ To create a free account, navigate to the [registration page](https://documen.so
|
|||||||
|
|
||||||
### Optional: Claim a Premium Username
|
### Optional: Claim a Premium Username
|
||||||
|
|
||||||
You can claim a premium username by upgrading to a paid plan. After upgrading to a paid plan, you can update your [public profile](https://app.documenso.com/settings/public-profile).
|
You can claim a premium username by upgrading to a paid plan. After upgrading to a paid plan, you can update your [public profile](/users/profile).
|
||||||
|
|
||||||
### Optional: Create a Team
|
### Optional: Create a Team
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Community Edition
|
||||||
|
description: Learn about the Community Edition of Documenso.
|
||||||
|
---
|
||||||
|
|
||||||
import { Callout } from 'nextra/components';
|
import { Callout } from 'nextra/components';
|
||||||
|
|
||||||
# Community Edition
|
# Community Edition
|
||||||
@@ -32,10 +37,10 @@ Documenso and the Community Edition are licensed under [AGPL3](https://github.co
|
|||||||
|
|
||||||
### Conditions
|
### Conditions
|
||||||
|
|
||||||
ℹ️ License and copyright notice
|
- License and copyright notice
|
||||||
ℹ️ State changes
|
- State changes
|
||||||
ℹ️ Disclose source
|
- Disclose source
|
||||||
ℹ️ Network use is distribution
|
- Network use is distribution
|
||||||
|
|
||||||
<Callout type="warning">
|
<Callout type="warning">
|
||||||
It's important to remember that you must keep the AGPL3 license for your modified or non-modified
|
It's important to remember that you must keep the AGPL3 license for your modified or non-modified
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Enterprise Edition
|
||||||
|
description: Learn about the Enterprise Edition of Documenso.
|
||||||
|
---
|
||||||
|
|
||||||
import { Callout } from 'nextra/components';
|
import { Callout } from 'nextra/components';
|
||||||
|
|
||||||
# Enterprise Edition
|
# Enterprise Edition
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Licenses
|
||||||
|
description: Learn about the different licenses for self-hosting Documenso.
|
||||||
|
---
|
||||||
|
|
||||||
# Self-Hosting Licenses
|
# Self-Hosting Licenses
|
||||||
|
|
||||||
Documenso comes in two versions for self-hosting:
|
Documenso comes in two versions for self-hosting:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Documenso allows you to create a public profile to share your templates for anyo
|
|||||||
|
|
||||||
### Navigate to Your Profile Settings
|
### Navigate to Your Profile Settings
|
||||||
|
|
||||||
Click on your profile picture in the top right corner and select "Settings" or "Team Settings". Then, navigate to the "Public Profile" tab to configure your profile.
|
Click on your profile picture in the top right corner and select "Team Settings". Then, navigate to the "Public Profile" tab to configure your profile.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -9,30 +9,30 @@ description: Learn what types of support we offer.
|
|||||||
|
|
||||||
If you are a developer or free user, you can reach out to the community or raise an issue:
|
If you are a developer or free user, you can reach out to the community or raise an issue:
|
||||||
|
|
||||||
### [Create Github Issues](https://github.com/documenso/documenso/issues)
|
**[Create Github Issues](https://github.com/documenso/documenso/issues)**
|
||||||
|
|
||||||
The community and the core team address GitHub issues. Be sure to check if a similar issue already exists. Please note that while we want to address everything immediately, we must prioritize.
|
The community and the core team address GitHub issues. Be sure to check if a similar issue already exists. Please note that while we want to address everything immediately, we must prioritize.
|
||||||
|
|
||||||
### [Join our Discord](https://documen.so/discord)
|
**[Join our Discord](https://documen.so/discord)**
|
||||||
|
|
||||||
You can ask for help in the [community help channel](https://discord.com/channels/1132216843537485854/1133419426524430376).
|
You can ask for help in the [community help channel](https://discord.com/channels/1132216843537485854/1133419426524430376).
|
||||||
|
|
||||||
## Paid Account Support
|
## Paid Account Support
|
||||||
|
|
||||||
### Email: support@documenso.com
|
**Email: support@documenso.com**
|
||||||
|
|
||||||
If you are paying customers facing issues, email our customer support, especially in urgent cases.
|
If you are paying customers facing issues, email our customer support, especially in urgent cases.
|
||||||
|
|
||||||
### Private Discord channel
|
**Private Discord channel**
|
||||||
|
|
||||||
If you prefer Discord, we can invite you to a private channel. Message support to make this happen.
|
If you prefer Discord, we can invite you to a private channel. Message support to make this happen.
|
||||||
|
|
||||||
## Enterprise Support
|
## Enterprise Support
|
||||||
|
|
||||||
### Email: support@documenso.com
|
**Email: support@documenso.com**
|
||||||
|
|
||||||
If you are paying customers facing issues, email our customer support, especially in urgent cases.
|
If you are paying customers facing issues, email our customer support, especially in urgent cases.
|
||||||
|
|
||||||
### Slack
|
**Slack**
|
||||||
|
|
||||||
If your team is on Slack, we can create a private workspace to support you more closely.
|
If your team is on Slack, we can create a private workspace to support you more closely.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
title: Templates
|
||||||
|
description: Learn how to create and use templates in Documenso.
|
||||||
|
---
|
||||||
|
|
||||||
import { Callout, Steps } from 'nextra/components';
|
import { Callout, Steps } from 'nextra/components';
|
||||||
|
|
||||||
# Document Templates
|
# Document Templates
|
||||||
|
|||||||
|
After Width: | Height: | Size: 466 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 590 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 49 KiB |
@@ -12,11 +12,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"next": "^15.5.7"
|
"next": "15.5.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "18.3.27",
|
"@types/react": "18.3.27",
|
||||||
"typescript": "5.6.2"
|
"typescript": "5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,8 +156,8 @@ export const AdminOrganisationMemberUpdateDialog = ({
|
|||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription className="mt-4">
|
||||||
<Trans>
|
<Trans>
|
||||||
You are currently updating{' '}
|
You are currently updating <span className="font-bold">{organisationMemberName}</span>
|
||||||
<span className="font-bold">{organisationMemberName}.</span>
|
.
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
type AiFeaturesEnableDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onEnabled: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AiFeaturesEnableDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onEnabled,
|
||||||
|
}: AiFeaturesEnableDialogProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
|
const isTeamAdmin = team.currentTeamRole === TeamMemberRole.ADMIN;
|
||||||
|
const isOrganisationAdmin = organisation.currentOrganisationRole === OrganisationMemberRole.ADMIN;
|
||||||
|
const canEnableAiFeatures = isTeamAdmin || isOrganisationAdmin;
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { mutateAsync: updateTeamSettings, isPending: isUpdatingTeamSettings } =
|
||||||
|
trpc.team.settings.update.useMutation();
|
||||||
|
const { mutateAsync: updateOrganisationSettings, isPending: isUpdatingOrganisationSettings } =
|
||||||
|
trpc.organisation.settings.update.useMutation();
|
||||||
|
|
||||||
|
const isSubmitting = isUpdatingTeamSettings || isUpdatingOrganisationSettings;
|
||||||
|
|
||||||
|
const onEnableClick = async () => {
|
||||||
|
if (!canEnableAiFeatures) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isTeamAdmin) {
|
||||||
|
await updateTeamSettings({
|
||||||
|
teamId: team.id,
|
||||||
|
data: { aiFeaturesEnabled: true },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await updateOrganisationSettings({
|
||||||
|
organisationId: organisation.id,
|
||||||
|
data: { aiFeaturesEnabled: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onEnabled();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to enable AI features', err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: t`We couldn't enable AI features right now. Please try again.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Trans>Enable AI features</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<Trans>
|
||||||
|
Turn on AI detection to automatically find recipients and fields in your documents. AI
|
||||||
|
providers do not retain your data for training.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
|
Your document content will be sent securely to our AI provider solely for detection
|
||||||
|
and will not be stored or used for training.
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{canEnableAiFeatures ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<Trans>
|
||||||
|
You're an admin. You can enable AI features for this team right away. Everyone on
|
||||||
|
the team will see AI detection once enabled.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<Trans>
|
||||||
|
AI features are disabled for your team. Please ask your team owner or organisation
|
||||||
|
owner to enable them.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{canEnableAiFeatures ? (
|
||||||
|
<Button type="button" onClick={() => void onEnableClick()} loading={isSubmitting}>
|
||||||
|
<Trans>Enable AI features</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
|
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
|
||||||
|
|
||||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||||
@@ -232,10 +232,19 @@ export const AiFieldDetectionDialog = ({
|
|||||||
|
|
||||||
{progress && (
|
{progress && (
|
||||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||||
<Trans>
|
<Plural
|
||||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
value={progress.fieldsDetected}
|
||||||
{progress.fieldsDetected} field(s) found
|
one={
|
||||||
</Trans>
|
<Trans>
|
||||||
|
Page {progress.pagesProcessed} of {progress.totalPages} - # field found
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
other={
|
||||||
|
<Trans>
|
||||||
|
Page {progress.pagesProcessed} of {progress.totalPages} - # fields found
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -279,7 +288,11 @@ export const AiFieldDetectionDialog = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
<Trans>We found {detectedFields.length} field(s) in your document.</Trans>
|
<Plural
|
||||||
|
value={detectedFields.length}
|
||||||
|
one="We found # field in your document."
|
||||||
|
other="We found # fields in your document."
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="mt-4 divide-y rounded-lg border">
|
<ul className="mt-4 divide-y rounded-lg border">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
|
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
@@ -190,10 +190,19 @@ export const AiRecipientDetectionDialog = ({
|
|||||||
|
|
||||||
{progress && (
|
{progress && (
|
||||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||||
<Trans>
|
<Plural
|
||||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
value={progress.recipientsDetected}
|
||||||
{progress.recipientsDetected} recipient(s) found
|
one={
|
||||||
</Trans>
|
<Trans>
|
||||||
|
Page {progress.pagesProcessed} of {progress.totalPages} - # recipient found
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
other={
|
||||||
|
<Trans>
|
||||||
|
Page {progress.pagesProcessed} of {progress.totalPages} - # recipients found
|
||||||
|
</Trans>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -237,9 +246,11 @@ export const AiRecipientDetectionDialog = ({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
<Trans>
|
<Plural
|
||||||
We found {detectedRecipients.length} recipient(s) in your document.
|
value={detectedRecipients.length}
|
||||||
</Trans>
|
one="We found # recipient in your document."
|
||||||
|
other="We found # recipients in your document."
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="mt-4 divide-y rounded-lg border">
|
<ul className="mt-4 divide-y rounded-lg border">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import * as z from 'zod';
|
|||||||
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -129,18 +130,43 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
|
|
||||||
const distributionMethod = watch('meta.distributionMethod');
|
const distributionMethod = watch('meta.distributionMethod');
|
||||||
|
|
||||||
|
const recipientsWithIndex = useMemo(
|
||||||
|
() =>
|
||||||
|
envelope.recipients.map((recipient, index) => ({
|
||||||
|
...recipient,
|
||||||
|
index,
|
||||||
|
})),
|
||||||
|
[envelope.recipients],
|
||||||
|
);
|
||||||
|
|
||||||
const recipientsMissingSignatureFields = useMemo(
|
const recipientsMissingSignatureFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
envelope.recipients.filter(
|
recipientsWithIndex.filter(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
recipient.role === RecipientRole.SIGNER &&
|
recipient.role === RecipientRole.SIGNER &&
|
||||||
!envelope.fields.some(
|
!envelope.fields.some(
|
||||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
(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(() => {
|
const invalidEnvelopeCode = useMemo(() => {
|
||||||
if (recipientsMissingSignatureFields.length > 0) {
|
if (recipientsMissingSignatureFields.length > 0) {
|
||||||
return 'MISSING_SIGNATURES';
|
return 'MISSING_SIGNATURES';
|
||||||
@@ -150,8 +176,12 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
return 'MISSING_RECIPIENTS';
|
return 'MISSING_RECIPIENTS';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recipientsMissingRequiredEmail.length > 0) {
|
||||||
|
return 'MISSING_REQUIRED_EMAIL';
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
|
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
|
||||||
|
|
||||||
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
||||||
try {
|
try {
|
||||||
@@ -323,8 +353,10 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Reply To Email</Trans>{' '}
|
<Trans>
|
||||||
<span className="text-muted-foreground">(Optional)</span>
|
Reply To Email{' '}
|
||||||
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -342,8 +374,10 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Subject</Trans>{' '}
|
<Trans>
|
||||||
<span className="text-muted-foreground">(Optional)</span>
|
Subject{' '}
|
||||||
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -360,8 +394,10 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="flex flex-row items-center">
|
<FormLabel className="flex flex-row items-center">
|
||||||
<Trans>Message</Trans>{' '}
|
<Trans>
|
||||||
<span className="text-muted-foreground">(Optional)</span>
|
Message{' '}
|
||||||
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
@@ -444,7 +480,22 @@ export const EnvelopeDistributeDialog = ({
|
|||||||
|
|
||||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||||
{recipientsMissingSignatureFields.map((recipient) => (
|
{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>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export const EnvelopeItemDeleteDialog = ({
|
|||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription className="mt-4">
|
||||||
<Trans>
|
<Trans>
|
||||||
You cannot delete this item because the document has been sent to recipients
|
You cannot delete this item because the document has been sent to recipients.
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
|
|||||||
<Trans>
|
<Trans>
|
||||||
Once you update your DNS records, it may take up to 48 hours for it to be propogated.
|
Once you update your DNS records, it may take up to 48 hours for it to be propogated.
|
||||||
Once the DNS propagation is complete you will need to come back and press the "Sync"
|
Once the DNS propagation is complete you will need to come back and press the "Sync"
|
||||||
domains button
|
domains button.
|
||||||
</Trans>
|
</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ export const OrganisationMemberUpdateDialog = ({
|
|||||||
|
|
||||||
<DialogDescription className="mt-4">
|
<DialogDescription className="mt-4">
|
||||||
<Trans>
|
<Trans>
|
||||||
You are currently updating{' '}
|
You are currently updating <span className="font-bold">{organisationMemberName}</span>
|
||||||
<span className="font-bold">{organisationMemberName}.</span>
|
.
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
const ZSignFieldEmailFormSchema = z.object({
|
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>;
|
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { DocumentSigningDisclosure } from '../general/document-signing/document-
|
|||||||
|
|
||||||
export type SignFieldSignatureDialogProps = {
|
export type SignFieldSignatureDialogProps = {
|
||||||
initialSignature?: string;
|
initialSignature?: string;
|
||||||
|
fullName?: string;
|
||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
@@ -28,6 +29,7 @@ export const SignFieldSignatureDialog = createCallable<
|
|||||||
>(
|
>(
|
||||||
({
|
({
|
||||||
call,
|
call,
|
||||||
|
fullName,
|
||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
@@ -46,6 +48,7 @@ export const SignFieldSignatureDialog = createCallable<
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
|
fullName={fullName}
|
||||||
value={localSignature ?? ''}
|
value={localSignature ?? ''}
|
||||||
onChange={({ value }) => setLocalSignature(value)}
|
onChange={({ value }) => setLocalSignature(value)}
|
||||||
typedSignatureEnabled={typedSignatureEnabled}
|
typedSignatureEnabled={typedSignatureEnabled}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -30,6 +31,13 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -53,26 +61,34 @@ export const TeamDeleteDialog = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { refreshSession } = useSession();
|
const { refreshSession } = useSession();
|
||||||
|
|
||||||
|
const currentOrganisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${teamName}`);
|
const deleteMessage = _(msg`delete ${teamName}`);
|
||||||
|
|
||||||
|
const filteredTeams = currentOrganisation.teams.filter((team) => team.id !== teamId);
|
||||||
|
|
||||||
const ZDeleteTeamFormSchema = z.object({
|
const ZDeleteTeamFormSchema = z.object({
|
||||||
teamName: z.literal(deleteMessage, {
|
teamName: z.literal(deleteMessage, {
|
||||||
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
||||||
}),
|
}),
|
||||||
|
transferTeamId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(ZDeleteTeamFormSchema),
|
resolver: zodResolver(ZDeleteTeamFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
teamName: '',
|
teamName: '',
|
||||||
|
transferTeamId: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
|
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async (data: z.infer<typeof ZDeleteTeamFormSchema>) => {
|
||||||
try {
|
try {
|
||||||
await deleteTeam({ teamId });
|
const transferTeamId = data.transferTeamId ? parseInt(data.transferTeamId, 10) : undefined;
|
||||||
|
|
||||||
|
await deleteTeam({ teamId, transferTeamId: transferTeamId || undefined });
|
||||||
|
|
||||||
await refreshSession();
|
await refreshSession();
|
||||||
|
|
||||||
@@ -168,6 +184,43 @@ export const TeamDeleteDialog = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{filteredTeams.length > 0 && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="transferTeamId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Transfer documents to a different team</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={_(msg`Don't transfer (Delete all documents)`)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="-1">
|
||||||
|
<Trans>Don't transfer (Delete all documents)</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
{filteredTeams.map((team) => (
|
||||||
|
<SelectItem key={team.id} value={team.id.toString()}>
|
||||||
|
{team.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const TeamMemberUpdateDialog = ({
|
|||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
You are currently updating <span className="font-bold">{memberName}.</span>
|
You are currently updating <span className="font-bold">{memberName}</span>.
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -137,12 +137,12 @@ export const TemplateBulkSendDialog = ({
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||||
<div className="bg-muted/70 rounded-lg border p-4">
|
<div className="rounded-lg border bg-muted/70 p-4">
|
||||||
<h3 className="text-sm font-medium">
|
<h3 className="text-sm font-medium">
|
||||||
<Trans>CSV Structure</Trans>
|
<Trans>CSV Structure</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
<Trans>
|
<Trans>
|
||||||
For each recipient, provide their email (required) and name (optional) in separate
|
For each recipient, provide their email (required) and name (optional) in separate
|
||||||
columns. Download the template CSV below for the correct format.
|
columns. Download the template CSV below for the correct format.
|
||||||
@@ -153,7 +153,7 @@ export const TemplateBulkSendDialog = ({
|
|||||||
<Trans>Current recipients:</Trans>
|
<Trans>Current recipients:</Trans>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
|
<ul className="mt-2 list-inside list-disc text-sm text-muted-foreground">
|
||||||
{recipients.map((recipient, index) => (
|
{recipients.map((recipient, index) => (
|
||||||
<li key={index}>
|
<li key={index}>
|
||||||
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
|
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
|
||||||
@@ -167,7 +167,7 @@ export const TemplateBulkSendDialog = ({
|
|||||||
<Trans>Download Template CSV</Trans>
|
<Trans>Download Template CSV</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-xs text-muted-foreground">
|
||||||
<Trans>Pre-formatted CSV template with example data.</Trans>
|
<Trans>Pre-formatted CSV template with example data.</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,14 +200,14 @@ export const TemplateBulkSendDialog = ({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex h-10 items-center rounded-md border px-3">
|
<div className="flex h-10 items-center rounded-md border px-3">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
<FileIcon className="text-muted-foreground h-4 w-4" />
|
<FileIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="flex-1 truncate text-sm">{value.name}</span>
|
<span className="flex-1 truncate text-sm">{value.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-destructive hover:text-destructive p-0 text-xs"
|
className="p-0 text-xs text-destructive hover:text-destructive"
|
||||||
onClick={() => onChange(null)}
|
onClick={() => onChange(null)}
|
||||||
disabled={form.formState.isSubmitting}
|
disabled={form.formState.isSubmitting}
|
||||||
>
|
>
|
||||||
@@ -220,9 +220,9 @@ export const TemplateBulkSendDialog = ({
|
|||||||
)}
|
)}
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{error && <p className="text-destructive text-sm">{error.message}</p>}
|
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||||
|
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-xs text-muted-foreground">
|
||||||
<Trans>
|
<Trans>
|
||||||
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
|
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
|
||||||
template defaults.
|
template defaults.
|
||||||
@@ -247,7 +247,7 @@ export const TemplateBulkSendDialog = ({
|
|||||||
|
|
||||||
<label
|
<label
|
||||||
htmlFor="send-immediately"
|
htmlFor="send-immediately"
|
||||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
className="ml-2 flex items-center text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Trans>Send documents to recipients immediately</Trans>
|
<Trans>Send documents to recipients immediately</Trans>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { FilePlus, Loader } from 'lucide-react';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
type TemplateCreateDialogProps = {
|
|
||||||
folderId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { user } = useSession();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
|
||||||
|
|
||||||
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
|
||||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
|
||||||
|
|
||||||
const onFileDrop = async (files: File[]) => {
|
|
||||||
const file = files[0];
|
|
||||||
|
|
||||||
if (isUploadingFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUploadingFile(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
title: file.name,
|
|
||||||
folderId: folderId,
|
|
||||||
} satisfies TCreateTemplatePayloadSchema;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append('payload', JSON.stringify(payload));
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const { envelopeId: id } = await createTemplate(formData);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Template document uploaded`),
|
|
||||||
description: _(
|
|
||||||
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
|
|
||||||
),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setShowTemplateCreateDialog(false);
|
|
||||||
|
|
||||||
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`Please try again later.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsUploadingFile(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={showTemplateCreateDialog}
|
|
||||||
onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="cursor-pointer" disabled={!user.emailVerified}>
|
|
||||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
|
||||||
<Trans>Template (Legacy)</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="w-full max-w-xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>New Template</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
Templates allow you to quickly generate documents with pre-filled recipients and
|
|
||||||
fields.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
|
|
||||||
|
|
||||||
{isUploadingFile && (
|
|
||||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
|
||||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="secondary" disabled={isUploadingFile}>
|
|
||||||
<Trans>Close</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
SKIP_QUERY_BATCH_META,
|
SKIP_QUERY_BATCH_META,
|
||||||
} from '@documenso/lib/constants/trpc';
|
} from '@documenso/lib/constants/trpc';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
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 { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -65,7 +66,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
|
|||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
email: z.string().email(),
|
email: ZRecipientEmailSchema,
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
@@ -100,12 +101,29 @@ export function TemplateUseDialog({
|
|||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
||||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
{
|
||||||
defaultValues: {
|
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,
|
distributeDocument: false,
|
||||||
useCustomDocument: false,
|
useCustomDocument: false,
|
||||||
customDocumentData: [],
|
customDocumentData: envelopeItems.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
data: undefined,
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
})),
|
||||||
recipients: recipients
|
recipients: recipients
|
||||||
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
||||||
.map((recipient) => {
|
.map((recipient) => {
|
||||||
@@ -124,7 +142,12 @@ export function TemplateUseDialog({
|
|||||||
signingOrder: recipient.signingOrder ?? undefined,
|
signingOrder: recipient.signingOrder ?? undefined,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
},
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||||
|
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||||
|
defaultValues: generateDefaultFormValues(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { replace, fields: localCustomDocumentData } = useFieldArray({
|
const { replace, fields: localCustomDocumentData } = useFieldArray({
|
||||||
@@ -132,19 +155,6 @@ export function TemplateUseDialog({
|
|||||||
name: 'customDocumentData',
|
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 } =
|
const { mutateAsync: createDocumentFromTemplate } =
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|
||||||
@@ -214,8 +224,8 @@ export function TemplateUseDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (open) {
|
||||||
form.reset();
|
form.reset(generateDefaultFormValues());
|
||||||
}
|
}
|
||||||
}, [open, form]);
|
}, [open, form]);
|
||||||
|
|
||||||
@@ -322,7 +332,7 @@ export function TemplateUseDialog({
|
|||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
aria-label="Name"
|
aria-label="Name"
|
||||||
placeholder={recipients[index].name || _(msg`Name`)}
|
placeholder={recipients[index].name || _(msg`Recipient ${index + 1}`)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -349,7 +359,7 @@ export function TemplateUseDialog({
|
|||||||
|
|
||||||
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
|
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||||
<label
|
<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"
|
htmlFor="distributeDocument"
|
||||||
>
|
>
|
||||||
<Trans>Send document</Trans>
|
<Trans>Send document</Trans>
|
||||||
@@ -358,7 +368,7 @@ export function TemplateUseDialog({
|
|||||||
<InfoIcon className="mx-1 h-4 w-4" />
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</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>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
The document will be immediately sent to recipients if this
|
The document will be immediately sent to recipients if this
|
||||||
@@ -378,7 +388,7 @@ export function TemplateUseDialog({
|
|||||||
|
|
||||||
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
|
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
|
||||||
<label
|
<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"
|
htmlFor="distributeDocument"
|
||||||
>
|
>
|
||||||
<Trans>Create as pending</Trans>
|
<Trans>Create as pending</Trans>
|
||||||
@@ -386,7 +396,7 @@ export function TemplateUseDialog({
|
|||||||
<TooltipTrigger type="button">
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="mx-1 h-4 w-4" />
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</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>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
Create the document as pending and ready to sign.
|
Create the document as pending and ready to sign.
|
||||||
@@ -432,7 +442,7 @@ export function TemplateUseDialog({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label
|
<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"
|
htmlFor="useCustomDocument"
|
||||||
>
|
>
|
||||||
<Trans>Upload custom document</Trans>
|
<Trans>Upload custom document</Trans>
|
||||||
@@ -440,7 +450,7 @@ export function TemplateUseDialog({
|
|||||||
<TooltipTrigger type="button">
|
<TooltipTrigger type="button">
|
||||||
<InfoIcon className="mx-1 h-4 w-4" />
|
<InfoIcon className="mx-1 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</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>
|
<p>
|
||||||
<Trans>
|
<Trans>
|
||||||
Upload a custom document to use instead of the template's default
|
Upload a custom document to use instead of the template's default
|
||||||
@@ -470,19 +480,19 @@ export function TemplateUseDialog({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
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="flex-shrink-0">
|
||||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<FileTextIcon className="text-primary h-5 w-5" />
|
<FileTextIcon className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<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}
|
{item.title}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
{field.value ? (
|
{field.value ? (
|
||||||
<div>
|
<div>
|
||||||
<Trans>
|
<Trans>
|
||||||
|
|||||||
@@ -219,9 +219,8 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
|||||||
<FormDescription>
|
<FormDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
A secret that will be sent to your URL so you can verify that the request
|
A secret that will be sent to your URL so you can verify that the request
|
||||||
has been sent by Documenso
|
has been sent by Documenso.
|
||||||
</Trans>
|
</Trans>
|
||||||
.
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ZDocumentMetaDateFormatSchema,
|
ZDocumentMetaDateFormatSchema,
|
||||||
ZDocumentMetaLanguageSchema,
|
ZDocumentMetaLanguageSchema,
|
||||||
} from '@documenso/lib/types/document-meta';
|
} from '@documenso/lib/types/document-meta';
|
||||||
|
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||||
|
|
||||||
// Define the schema for configuration
|
// Define the schema for configuration
|
||||||
@@ -55,7 +56,7 @@ export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.exten
|
|||||||
nativeId: z.number().optional(),
|
nativeId: z.number().optional(),
|
||||||
formId: z.string(),
|
formId: z.string(),
|
||||||
name: 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']),
|
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
disabled: z.boolean().optional(),
|
disabled: z.boolean().optional(),
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
className="mt-2"
|
className="mt-2"
|
||||||
disabled={isThrottled || isSubmitting}
|
disabled={isThrottled || isSubmitting}
|
||||||
disableAnimation
|
disableAnimation
|
||||||
|
fullName={fullName}
|
||||||
value={signature ?? ''}
|
value={signature ?? ''}
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
onChange={(v) => setSignature(v ?? '')}
|
||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
|
|||||||
@@ -455,6 +455,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
className="mt-2"
|
className="mt-2"
|
||||||
disabled={isThrottled || isSubmitting}
|
disabled={isThrottled || isSubmitting}
|
||||||
disableAnimation
|
disableAnimation
|
||||||
|
fullName={fullName}
|
||||||
value={signature ?? ''}
|
value={signature ?? ''}
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
onChange={(v) => setSignature(v ?? '')}
|
||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
className="mt-2"
|
className="mt-2"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
disableAnimation
|
disableAnimation
|
||||||
|
fullName={fullName}
|
||||||
value={signature ?? ''}
|
value={signature ?? ''}
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
onChange={(v) => setSignature(v ?? '')}
|
||||||
typedSignatureEnabled={
|
typedSignatureEnabled={
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export type TDocumentPreferencesFormSchema = {
|
|||||||
includeSigningCertificate: boolean | null;
|
includeSigningCertificate: boolean | null;
|
||||||
includeAuditLog: boolean | null;
|
includeAuditLog: boolean | null;
|
||||||
signatureTypes: DocumentSignatureType[];
|
signatureTypes: DocumentSignatureType[];
|
||||||
|
delegateDocumentOwnership: boolean | null;
|
||||||
aiFeaturesEnabled: boolean | null;
|
aiFeaturesEnabled: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ type SettingsSubset = Pick<
|
|||||||
| 'typedSignatureEnabled'
|
| 'typedSignatureEnabled'
|
||||||
| 'uploadSignatureEnabled'
|
| 'uploadSignatureEnabled'
|
||||||
| 'drawSignatureEnabled'
|
| 'drawSignatureEnabled'
|
||||||
|
| 'delegateDocumentOwnership'
|
||||||
| 'aiFeaturesEnabled'
|
| 'aiFeaturesEnabled'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -109,6 +111,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
}),
|
}),
|
||||||
|
delegateDocumentOwnership: z.boolean().nullable(),
|
||||||
aiFeaturesEnabled: z.boolean().nullable(),
|
aiFeaturesEnabled: z.boolean().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,6 +128,7 @@ export const DocumentPreferencesForm = ({
|
|||||||
includeSigningCertificate: settings.includeSigningCertificate,
|
includeSigningCertificate: settings.includeSigningCertificate,
|
||||||
includeAuditLog: settings.includeAuditLog,
|
includeAuditLog: settings.includeAuditLog,
|
||||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||||
|
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||||
@@ -515,6 +519,52 @@ export const DocumentPreferencesForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="delegateDocumentOwnership"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Delegate Document Ownership</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
value={field.value === null ? '-1' : field.value.toString()}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">
|
||||||
|
<Trans>Yes</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
<SelectItem value="false">
|
||||||
|
<Trans>No</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
{canInherit && (
|
||||||
|
<SelectItem value={'-1'}>
|
||||||
|
<Trans>Inherit from organisation</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>
|
||||||
|
Enable team API tokens to delegate document ownership to another team member.
|
||||||
|
</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{isAiFeaturesConfigured && (
|
{isAiFeaturesConfigured && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
|||||||
<Label htmlFor="email" className="text-muted-foreground">
|
<Label htmlFor="email" className="text-muted-foreground">
|
||||||
<Trans>Email</Trans>
|
<Trans>Email</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
<Input id="email" type="email" className="mt-2 bg-muted" value={user.email} disabled />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -124,6 +124,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePadDialog
|
<SignaturePadDialog
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
fullName={user.name ?? ''}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -244,11 +244,11 @@ export const SignInForm = ({
|
|||||||
const errorMessage = match(error.code)
|
const errorMessage = match(error.code)
|
||||||
.with(
|
.with(
|
||||||
AuthenticationErrorCode.InvalidCredentials,
|
AuthenticationErrorCode.InvalidCredentials,
|
||||||
() => msg`The email or password provided is incorrect`,
|
() => msg`The email or password provided is incorrect.`,
|
||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
AuthenticationErrorCode.InvalidTwoFactorCode,
|
AuthenticationErrorCode.InvalidTwoFactorCode,
|
||||||
() => msg`The two-factor authentication code provided is incorrect`,
|
() => msg`The two-factor authentication code provided is incorrect.`,
|
||||||
)
|
)
|
||||||
.otherwise(() => handleFallbackErrorMessages(error.code));
|
.otherwise(() => handleFallbackErrorMessages(error.code));
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ export const SignInForm = ({
|
|||||||
<p className="mt-2 text-right">
|
<p className="mt-2 text-right">
|
||||||
<Link
|
<Link
|
||||||
to="/forgot-password"
|
to="/forgot-password"
|
||||||
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
className="text-sm text-muted-foreground duration-200 hover:opacity-70"
|
||||||
>
|
>
|
||||||
<Trans>Forgot your password?</Trans>
|
<Trans>Forgot your password?</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -390,11 +390,11 @@ export const SignInForm = ({
|
|||||||
<>
|
<>
|
||||||
{hasSocialAuthEnabled && (
|
{hasSocialAuthEnabled && (
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="h-px flex-1 bg-border" />
|
||||||
<span className="text-muted-foreground bg-transparent">
|
<span className="bg-transparent text-muted-foreground">
|
||||||
<Trans>Or continue with</Trans>
|
<Trans>Or continue with</Trans>
|
||||||
</span>
|
</span>
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="h-px flex-1 bg-border" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -403,7 +403,7 @@ export const SignInForm = ({
|
|||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="border bg-background text-muted-foreground"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithGoogleClick}
|
onClick={onSignInWithGoogleClick}
|
||||||
>
|
>
|
||||||
@@ -417,7 +417,7 @@ export const SignInForm = ({
|
|||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="border bg-background text-muted-foreground"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithMicrosoftClick}
|
onClick={onSignInWithMicrosoftClick}
|
||||||
>
|
>
|
||||||
@@ -435,7 +435,7 @@ export const SignInForm = ({
|
|||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="border bg-background text-muted-foreground"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithOIDCClick}
|
onClick={onSignInWithOIDCClick}
|
||||||
>
|
>
|
||||||
@@ -452,7 +452,7 @@ export const SignInForm = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
loading={isPasskeyLoading}
|
loading={isPasskeyLoading}
|
||||||
className="bg-background text-muted-foreground border"
|
className="border bg-background text-muted-foreground"
|
||||||
onClick={onSignInWithPasskey}
|
onClick={onSignInWithPasskey}
|
||||||
>
|
>
|
||||||
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
|
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
|||||||
const errorMessage = match(error.code)
|
const errorMessage = match(error.code)
|
||||||
.with(
|
.with(
|
||||||
AppErrorCode.UNAUTHORIZED,
|
AppErrorCode.UNAUTHORIZED,
|
||||||
() => msg`You do not have permission to create a token for this team`,
|
() => msg`You do not have permission to create a token for this team.`,
|
||||||
)
|
)
|
||||||
.otherwise(() => msg`Something went wrong. Please try again later.`);
|
.otherwise(() => msg`Something went wrong. Please try again later.`);
|
||||||
|
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="text-muted-foreground text-sm"
|
className="text-sm text-muted-foreground"
|
||||||
title={
|
title={
|
||||||
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
|
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p>{recipient.email}</p>
|
<p>{recipient.email || recipient.name}</p>
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
<p className="text-xs text-muted-foreground/70">
|
||||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
<SignaturePadDialog
|
<SignaturePadDialog
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
fullName={fullName}
|
||||||
value={signature ?? ''}
|
value={signature ?? ''}
|
||||||
onChange={(value) => setSignature(value)}
|
onChange={(value) => setSignature(value)}
|
||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
@@ -433,7 +434,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
|
|
||||||
<div className="mt-4 flex gap-x-4">
|
<div className="mt-4 flex gap-x-4">
|
||||||
<Button
|
<Button
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
|||||||
@@ -57,12 +57,13 @@ export type DocumentSigningCompleteDialogProps = {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
directTemplatePayload?: {
|
recipientPayload?: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
buttonSize?: 'sm' | 'lg';
|
buttonSize?: 'sm' | 'lg';
|
||||||
position?: 'start' | 'end' | 'center';
|
position?: 'start' | 'end' | 'center';
|
||||||
|
disableNameInput?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ZNextSignerFormSchema = z.object({
|
const ZNextSignerFormSchema = z.object({
|
||||||
@@ -89,10 +90,11 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
recipient,
|
recipient,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
allowDictateNextSigner = false,
|
allowDictateNextSigner = false,
|
||||||
directTemplatePayload,
|
recipientPayload,
|
||||||
defaultNextSigner,
|
defaultNextSigner,
|
||||||
buttonSize = 'lg',
|
buttonSize = 'lg',
|
||||||
position,
|
position,
|
||||||
|
disableNameInput = false,
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
}: DocumentSigningCompleteDialogProps) => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@@ -113,11 +115,11 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
|
const recipientForm = useForm<TDirectRecipientFormSchema>({
|
||||||
resolver: zodResolver(ZDirectRecipientFormSchema),
|
resolver: zodResolver(ZDirectRecipientFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: directTemplatePayload?.name ?? '',
|
name: recipientPayload?.name ?? '',
|
||||||
email: directTemplatePayload?.email ?? '',
|
email: recipientPayload?.email ?? '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,16 +147,16 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||||
try {
|
try {
|
||||||
let directRecipient: { name: string; email: string } | undefined;
|
let recipientOverridePayload: { name: string; email: string } | undefined;
|
||||||
|
|
||||||
if (directTemplatePayload && !directTemplatePayload.email) {
|
if (recipientPayload && !recipientPayload.email) {
|
||||||
const isFormValid = await directRecipientForm.trigger();
|
const isFormValid = await recipientForm.trigger();
|
||||||
|
|
||||||
if (!isFormValid) {
|
if (!isFormValid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
directRecipient = directRecipientForm.getValues();
|
recipientOverridePayload = recipientForm.getValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if 2FA is required
|
// Check if 2FA is required
|
||||||
@@ -168,7 +170,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
? { name: data.name, email: data.email }
|
? { name: data.name, email: data.email }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
|
await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = AppError.parseError(error);
|
const err = AppError.parseError(error);
|
||||||
|
|
||||||
@@ -222,7 +224,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
<Trans>Are you sure?</Trans>
|
<Trans>Are you sure?</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="max-w-[50ch] text-muted-foreground">
|
||||||
{match(recipient.role)
|
{match(recipient.role)
|
||||||
.with(RecipientRole.VIEWER, () => (
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-flex flex-wrap">
|
||||||
@@ -250,19 +252,19 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
|
<div className="rounded-lg border border-border bg-muted/50 p-4 text-center">
|
||||||
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
|
<p className="text-sm font-medium text-muted-foreground">{documentTitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!showTwoFactorForm && (
|
{!showTwoFactorForm && (
|
||||||
<>
|
<>
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||||
{directTemplatePayload && !directTemplatePayload.email && (
|
{recipientPayload && !recipientPayload.email && (
|
||||||
<Form {...directRecipientForm}>
|
<Form {...recipientForm}>
|
||||||
<div className="mb-4 flex flex-col gap-4">
|
<div className="mb-4 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
<FormField
|
<FormField
|
||||||
control={directRecipientForm.control}
|
control={recipientForm.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
@@ -274,7 +276,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
{...field}
|
{...field}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
placeholder={t`Enter your name`}
|
placeholder={t`Enter your name`}
|
||||||
disabled={isNameLocked}
|
disabled={isNameLocked || disableNameInput}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@@ -284,7 +286,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={directRecipientForm.control}
|
control={recipientForm.control}
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ export const DocumentSigningForm = ({
|
|||||||
<SignaturePadDialog
|
<SignaturePadDialog
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
fullName={fullName}
|
||||||
value={signature ?? ''}
|
value={signature ?? ''}
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
onChange={(v) => setSignature(v ?? '')}
|
||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export const DocumentSigningMobileWidget = () => {
|
|||||||
<motion.div
|
<motion.div
|
||||||
layout="size"
|
layout="size"
|
||||||
layoutId="document-signing-mobile-widget-progress-bar"
|
layoutId="document-signing-mobile-widget-progress-bar"
|
||||||
className="bg-documenso absolute inset-y-0 left-0"
|
className="bg-primary absolute inset-y-0 left-0"
|
||||||
style={{
|
style={{
|
||||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
<motion.div
|
<motion.div
|
||||||
layout="size"
|
layout="size"
|
||||||
layoutId="document-flow-container-step"
|
layoutId="document-flow-container-step"
|
||||||
className="absolute inset-y-0 left-0 bg-documenso"
|
className="absolute inset-y-0 left-0 bg-primary"
|
||||||
style={{
|
style={{
|
||||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -56,8 +56,11 @@ export const DocumentSigningSignatureField = ({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [fontSize, setFontSize] = useState(2);
|
const [fontSize, setFontSize] = useState(2);
|
||||||
|
|
||||||
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
const {
|
||||||
useRequiredDocumentSigningContext();
|
fullName,
|
||||||
|
signature: providedSignature,
|
||||||
|
setSignature: setProvidedSignature,
|
||||||
|
} = useRequiredDocumentSigningContext();
|
||||||
|
|
||||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
@@ -236,13 +239,13 @@ export const DocumentSigningSignatureField = ({
|
|||||||
type="Signature"
|
type="Signature"
|
||||||
>
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-background">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="h-5 w-5 animate-spin text-primary md:h-8 md:w-8" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state === 'empty' && (
|
{state === 'empty' && (
|
||||||
<p className="group-hover:text-primary font-signature text-muted-foreground group-hover:text-recipient-green text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200">
|
<p className="font-signature text-[clamp(0.575rem,25cqw,1.2rem)] text-xl text-muted-foreground duration-200 group-hover:text-primary group-hover:text-recipient-green">
|
||||||
<Trans>Signature</Trans>
|
<Trans>Signature</Trans>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -259,7 +262,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
|
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
|
||||||
<p
|
<p
|
||||||
ref={signatureRef}
|
ref={signatureRef}
|
||||||
className="font-signature text-muted-foreground w-full overflow-hidden break-all text-center leading-tight duration-200"
|
className="w-full overflow-hidden break-all text-center font-signature leading-tight text-muted-foreground duration-200"
|
||||||
style={{ fontSize: `${fontSize}rem` }}
|
style={{ fontSize: `${fontSize}rem` }}
|
||||||
>
|
>
|
||||||
{signature?.typedSignature}
|
{signature?.typedSignature}
|
||||||
@@ -272,12 +275,13 @@ export const DocumentSigningSignatureField = ({
|
|||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>
|
<Trans>
|
||||||
Sign as {recipient.name}{' '}
|
Sign as {recipient.name}{' '}
|
||||||
<div className="text-muted-foreground h-5">({recipient.email})</div>
|
<div className="h-5 text-muted-foreground">({recipient.email})</div>
|
||||||
</Trans>
|
</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
|
fullName={fullName}
|
||||||
value={localSignature ?? ''}
|
value={localSignature ?? ''}
|
||||||
onChange={({ value }) => setLocalSignature(value)}
|
onChange={({ value }) => setLocalSignature(value)}
|
||||||
typedSignatureEnabled={typedSignatureEnabled}
|
typedSignatureEnabled={typedSignatureEnabled}
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
|||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
||||||
|
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
|
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
|
||||||
import {
|
import {
|
||||||
@@ -31,9 +32,13 @@ import { useCurrentTeam } from '~/providers/team';
|
|||||||
|
|
||||||
export type DocumentUploadButtonLegacyProps = {
|
export type DocumentUploadButtonLegacyProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
type: EnvelopeType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLegacyProps) => {
|
export const DocumentUploadButtonLegacy = ({
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
}: DocumentUploadButtonLegacyProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
@@ -54,8 +59,18 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
|
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
|
||||||
|
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||||
|
|
||||||
const disabledMessage = useMemo(() => {
|
const disabledMessage = useMemo(() => {
|
||||||
|
if (!user.emailVerified) {
|
||||||
|
return msg`Verify your email to upload documents.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No errors for templates.
|
||||||
|
if (type === EnvelopeType.TEMPLATE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (organisation.subscription && remaining.documents === 0) {
|
if (organisation.subscription && remaining.documents === 0) {
|
||||||
return msg`Document upload disabled due to unpaid invoices`;
|
return msg`Document upload disabled due to unpaid invoices`;
|
||||||
}
|
}
|
||||||
@@ -64,11 +79,8 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
|||||||
return msg`You have reached your document limit.`;
|
return msg`You have reached your document limit.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.emailVerified) {
|
|
||||||
return msg`Verify your email to upload documents.`;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [remaining.documents, user.emailVerified, team]);
|
}, [remaining.documents, user.emailVerified, team, type]);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
@@ -80,44 +92,62 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
|||||||
meta: {
|
meta: {
|
||||||
timezone: userTimezone,
|
timezone: userTimezone,
|
||||||
},
|
},
|
||||||
} satisfies TCreateDocumentPayloadSchema;
|
} satisfies TCreateDocumentPayloadSchema | TCreateTemplatePayloadSchema;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
formData.append('payload', JSON.stringify(payload));
|
formData.append('payload', JSON.stringify(payload));
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
const { envelopeId: id } = await createDocument(formData);
|
// Handle legacy document creation.
|
||||||
|
if (type === EnvelopeType.DOCUMENT) {
|
||||||
|
const { envelopeId: id } = await createDocument(formData);
|
||||||
|
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
|
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document uploaded`),
|
title: _(msg`Document uploaded`),
|
||||||
description: _(msg`Your document has been uploaded successfully.`),
|
description: _(msg`Your document has been uploaded successfully.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: Document Uploaded', {
|
analytics.capture('App: Document Uploaded', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
documentId: id,
|
documentId: id,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle legacy template creation.
|
||||||
|
if (type === EnvelopeType.TEMPLATE) {
|
||||||
|
const { envelopeId: id } = await createTemplate(formData);
|
||||||
|
|
||||||
|
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Template document uploaded`),
|
||||||
|
description: _(
|
||||||
|
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
|
||||||
|
),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
const errorMessage = match(error.code)
|
const errorMessage = match(error.code)
|
||||||
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
|
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs.`)
|
||||||
.with(
|
.with(
|
||||||
AppErrorCode.LIMIT_EXCEEDED,
|
AppErrorCode.LIMIT_EXCEEDED,
|
||||||
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||||
() => msg`You have reached the limit of the number of files per envelope`,
|
() => msg`You have reached the limit of the number of files per envelope.`,
|
||||||
)
|
)
|
||||||
.otherwise(() => msg`An error occurred while uploading your document.`);
|
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||||
|
|
||||||
@@ -149,17 +179,18 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
|||||||
<div>
|
<div>
|
||||||
<DocumentUploadButtonPrimitive
|
<DocumentUploadButtonPrimitive
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
disabled={disabledMessage !== undefined}
|
||||||
disabledMessage={disabledMessage}
|
disabledMessage={disabledMessage}
|
||||||
onDrop={async (files) => onFileDrop(files[0])}
|
onDrop={async (files) => onFileDrop(files[0])}
|
||||||
onDropRejected={onFileDropRejected}
|
onDropRejected={onFileDropRejected}
|
||||||
type={EnvelopeType.DOCUMENT}
|
type={type}
|
||||||
internalVersion="1"
|
internalVersion="1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
{team?.id === undefined &&
|
{team?.id === undefined &&
|
||||||
|
type === EnvelopeType.DOCUMENT &&
|
||||||
remaining.documents > 0 &&
|
remaining.documents > 0 &&
|
||||||
Number.isFinite(remaining.documents) && (
|
Number.isFinite(remaining.documents) && (
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||||
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useRevalidator, useSearchParams } from 'react-router';
|
||||||
import { isDeepEqual } from 'remeda';
|
import { isDeepEqual } from 'remeda';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
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 { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
||||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||||
@@ -81,6 +82,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||||
|
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const selectedField = useMemo(
|
const selectedField = useMemo(
|
||||||
() => structuredClone(editorFields.selectedField),
|
() => structuredClone(editorFields.selectedField),
|
||||||
@@ -135,6 +138,22 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
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 (
|
return (
|
||||||
<div className="relative flex h-full">
|
<div className="relative flex h-full">
|
||||||
<div className="flex w-full flex-col overflow-y-auto">
|
<div className="flex w-full flex-col overflow-y-auto">
|
||||||
@@ -230,34 +249,36 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{team.preferences.aiFeaturesEnabled && (
|
<Button
|
||||||
<>
|
type="button"
|
||||||
<Button
|
variant="outline"
|
||||||
type="button"
|
size="sm"
|
||||||
variant="outline"
|
className="mt-4 w-full"
|
||||||
size="sm"
|
onClick={onDetectClick}
|
||||||
className="mt-4 w-full"
|
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||||
onClick={() => setIsAiFieldDialogOpen(true)}
|
title={
|
||||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
envelope.status !== DocumentStatus.DRAFT
|
||||||
title={
|
? _(msg`You can only detect fields in draft envelopes`)
|
||||||
envelope.status !== DocumentStatus.DRAFT
|
: undefined
|
||||||
? _(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>
|
||||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
</Button>
|
||||||
<Trans>Detect with AI</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AiFieldDetectionDialog
|
<AiFieldDetectionDialog
|
||||||
open={isAiFieldDialogOpen}
|
open={isAiFieldDialogOpen}
|
||||||
onOpenChange={setIsAiFieldDialogOpen}
|
onOpenChange={setIsAiFieldDialogOpen}
|
||||||
onComplete={onFieldDetectionComplete}
|
onComplete={onFieldDetectionComplete}
|
||||||
envelopeId={envelope.id}
|
envelopeId={envelope.id}
|
||||||
teamId={envelope.teamId}
|
teamId={envelope.teamId}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
<AiFeaturesEnableDialog
|
||||||
|
open={isAiEnableDialogOpen}
|
||||||
|
onOpenChange={setIsAiEnableDialogOpen}
|
||||||
|
onEnabled={onAiFeaturesEnabled}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Field details section. */}
|
{/* Field details section. */}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
type SensorAPI,
|
type SensorAPI,
|
||||||
} from '@hello-pangea/dnd';
|
} from '@hello-pangea/dnd';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
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 { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
|
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
|
||||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useRevalidator, useSearchParams } from 'react-router';
|
||||||
import { isDeepEqual, prop, sortBy } from 'remeda';
|
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
ZRecipientActionAuthTypesSchema,
|
ZRecipientActionAuthTypesSchema,
|
||||||
ZRecipientAuthOptionsSchema,
|
ZRecipientAuthOptionsSchema,
|
||||||
} from '@documenso/lib/types/document-auth';
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -62,6 +63,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
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 { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
@@ -70,10 +72,7 @@ const ZEnvelopeRecipientsForm = z.object({
|
|||||||
z.object({
|
z.object({
|
||||||
formId: z.string().min(1),
|
formId: z.string().min(1),
|
||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
email: z
|
email: ZRecipientEmailSchema,
|
||||||
.string()
|
|
||||||
.email({ message: msg`Invalid email`.id })
|
|
||||||
.min(1),
|
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
signingOrder: z.number().optional(),
|
signingOrder: z.number().optional(),
|
||||||
@@ -99,11 +98,19 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||||
|
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||||
|
|
||||||
// AI recipient detection dialog state
|
// AI recipient detection dialog state
|
||||||
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
|
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const onAiDialogOpenChange = (open: boolean) => {
|
const onAiDialogOpenChange = (open: boolean) => {
|
||||||
|
if (open && !team.preferences.aiFeaturesEnabled) {
|
||||||
|
setIsAiEnableDialogOpen(true);
|
||||||
|
setIsAiDialogOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsAiDialogOpen(open);
|
setIsAiDialogOpen(open);
|
||||||
|
|
||||||
if (!open && searchParams.get('ai') === 'true') {
|
if (!open && searchParams.get('ai') === 'true') {
|
||||||
@@ -120,6 +127,22 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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);
|
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||||
|
|
||||||
const initialId = useId();
|
const initialId = useId();
|
||||||
@@ -228,12 +251,13 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
keyName: 'nativeId',
|
keyName: 'nativeId',
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptySigners = useCallback(
|
const emptySignerIndex = watchedSigners.findIndex(
|
||||||
() => form.getValues('signers').filter((signer) => signer.email === ''),
|
(signer) =>
|
||||||
[form],
|
!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(
|
const isUserAlreadyARecipient = watchedSigners.some(
|
||||||
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
|
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
|
||||||
);
|
);
|
||||||
@@ -331,8 +355,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Recipients added`,
|
title: plural(detectedRecipients.length, {
|
||||||
description: t`${detectedRecipients.length} recipient(s) have been added from AI detection.`,
|
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.`,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -558,21 +588,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formValueSigners = formValues.signers || [];
|
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
|
||||||
|
|
||||||
// Remove the last signer if it's empty.
|
|
||||||
const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
|
|
||||||
if (i === formValueSigners.length - 1 && signer.email === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
|
|
||||||
...formValues,
|
|
||||||
signers: nonEmptyRecipients,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!validatedFormValues.success) {
|
if (!validatedFormValues.success) {
|
||||||
return;
|
return;
|
||||||
@@ -641,25 +657,27 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center space-x-2">
|
<div className="flex flex-row items-center space-x-2">
|
||||||
{team.preferences.aiFeaturesEnabled && (
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
type="button"
|
||||||
type="button"
|
size="sm"
|
||||||
size="sm"
|
disabled={isSubmitting}
|
||||||
disabled={isSubmitting}
|
onClick={onDetectRecipientsClick}
|
||||||
onClick={() => setIsAiDialogOpen(true)}
|
>
|
||||||
>
|
<SparklesIcon className="h-4 w-4" />
|
||||||
<SparklesIcon className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
{team.preferences.aiFeaturesEnabled ? (
|
||||||
<Trans>Detect recipients with AI</Trans>
|
<Trans>Detect recipients with AI</Trans>
|
||||||
</TooltipContent>
|
) : (
|
||||||
</Tooltip>
|
<Trans>Enable AI detection</Trans>
|
||||||
)}
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -736,9 +754,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={
|
disabled={isSubmitting || hasDocumentBeenSent}
|
||||||
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
@@ -924,7 +940,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{!showAdvancedSettings && index === 0 && (
|
{!showAdvancedSettings && index === 0 && (
|
||||||
<FormLabel required>
|
<FormLabel>
|
||||||
<Trans>Email</Trans>
|
<Trans>Email</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
)}
|
)}
|
||||||
@@ -978,7 +994,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<RecipientAutoCompleteInput
|
<RecipientAutoCompleteInput
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t`Name`}
|
placeholder={t`Recipient ${index + 1}`}
|
||||||
{...field}
|
{...field}
|
||||||
disabled={
|
disabled={
|
||||||
snapshot.isDragging ||
|
snapshot.isDragging ||
|
||||||
@@ -1118,6 +1134,12 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
envelopeId={envelope.id}
|
envelopeId={envelope.id}
|
||||||
teamId={envelope.teamId}
|
teamId={envelope.teamId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AiFeaturesEnableDialog
|
||||||
|
open={isAiEnableDialogOpen}
|
||||||
|
onOpenChange={setIsAiEnableDialogOpen}
|
||||||
|
onEnabled={onAiFeaturesEnabled}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -687,8 +687,10 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Reply To Email</Trans>{' '}
|
<Trans>
|
||||||
<span className="text-muted-foreground">(Optional)</span>
|
Reply To Email{' '}
|
||||||
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -726,8 +728,9 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="flex flex-row items-center">
|
<FormLabel className="flex flex-row items-center">
|
||||||
<Trans>Message</Trans>{' '}
|
<Trans>
|
||||||
<span className="text-muted-foreground">(Optional)</span>
|
Message <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Trans>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export default function EnvelopeEditor() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
layout="size"
|
layout="size"
|
||||||
layoutId="document-flow-container-step"
|
layoutId="document-flow-container-step"
|
||||||
className="absolute inset-y-0 left-0 bg-documenso"
|
className="absolute inset-y-0 left-0 bg-primary"
|
||||||
style={{
|
style={{
|
||||||
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
|
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import type { I18n } from '@lingui/core';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Field, Recipient } from '@prisma/client';
|
import type { Field, Recipient } from '@prisma/client';
|
||||||
@@ -39,8 +41,15 @@ export const EnvelopeRecipientSelector = ({
|
|||||||
fields,
|
fields,
|
||||||
align = 'start',
|
align = 'start',
|
||||||
}: EnvelopeRecipientSelectorProps) => {
|
}: EnvelopeRecipientSelectorProps) => {
|
||||||
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||||
|
|
||||||
|
const getRecipientLabel = useCallback(
|
||||||
|
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||||
|
[recipients],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -49,7 +58,7 @@ export const EnvelopeRecipientSelector = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className={cn(
|
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(
|
getRecipientColorStyles(
|
||||||
Math.max(
|
Math.max(
|
||||||
recipients.findIndex((r) => r.id === selectedRecipient?.id),
|
recipients.findIndex((r) => r.id === selectedRecipient?.id),
|
||||||
@@ -59,16 +68,12 @@ export const EnvelopeRecipientSelector = ({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedRecipient?.email && (
|
{selectedRecipient && (
|
||||||
<span className="flex-1 truncate text-left">
|
<span className="flex-1 truncate text-left">
|
||||||
{selectedRecipient?.name} ({selectedRecipient?.email})
|
{getRecipientLabel(selectedRecipient)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!selectedRecipient?.email && (
|
|
||||||
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -105,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||||||
fields,
|
fields,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: EnvelopeRecipientSelectorCommandProps) => {
|
}: EnvelopeRecipientSelectorCommandProps) => {
|
||||||
const { t } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
|
|
||||||
const recipientsByRole = useCallback(() => {
|
const recipientsByRole = useCallback(() => {
|
||||||
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||||
@@ -154,6 +159,11 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||||||
[fields, recipients],
|
[fields, recipients],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getRecipientLabel = useCallback(
|
||||||
|
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||||
|
[recipients],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Command
|
<Command
|
||||||
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
|
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
|
||||||
@@ -162,21 +172,21 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||||||
<CommandInput placeholder={placeholder} />
|
<CommandInput placeholder={placeholder} />
|
||||||
|
|
||||||
<CommandEmpty>
|
<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>
|
<Trans>No recipient matching this description was found.</Trans>
|
||||||
</span>
|
</span>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
|
|
||||||
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
|
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
|
||||||
<CommandGroup key={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)}
|
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{roleRecipients.length === 0 && (
|
{roleRecipients.length === 0 && (
|
||||||
<div
|
<div
|
||||||
key={`${role}-empty`}
|
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>
|
<Trans>No recipients with this role</Trans>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,18 +215,12 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn('text-foreground/70 truncate', {
|
className={cn('truncate text-foreground/70', {
|
||||||
'text-foreground/80': recipient.id === selectedRecipient?.id,
|
'text-foreground/80': recipient.id === selectedRecipient?.id,
|
||||||
'opacity-50': isRecipientDisabled(recipient.id),
|
'opacity-50': isRecipientDisabled(recipient.id),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{recipient.name && (
|
{getRecipientLabel(recipient)}
|
||||||
<span title={`${recipient.name} (${recipient.email})`}>
|
|
||||||
{recipient.name} ({recipient.email})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center justify-center">
|
<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" />
|
<Info className="z-50 ml-2 h-4 w-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
||||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||||
<Trans>
|
<Trans>
|
||||||
This document has already been sent to this recipient. You can no longer
|
This document has already been sent to this recipient. You can no longer
|
||||||
edit this recipient.
|
edit this recipient.
|
||||||
@@ -250,3 +254,22 @@ export const EnvelopeRecipientSelectorCommand = ({
|
|||||||
</Command>
|
</Command>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
|
||||||
|
if (recipient.name && recipient.email) {
|
||||||
|
return `${recipient.name} (${recipient.email})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.name) {
|
||||||
|
return recipient.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.email) {
|
||||||
|
return recipient.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since objects are basically pointers we can use `indexOf` rather than `findIndex`
|
||||||
|
const index = recipients.indexOf(recipient);
|
||||||
|
|
||||||
|
return i18n._(msg`Recipient ${index + 1}`);
|
||||||
|
};
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function EnvelopeSignerForm() {
|
|||||||
|
|
||||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||||
return (
|
return (
|
||||||
<fieldset className="embed--DocumentWidgetForm dark:bg-background border-border rounded-2xl sm:border sm:p-3">
|
<fieldset className="embed--DocumentWidgetForm rounded-2xl border-border sm:border sm:p-3 dark:bg-background">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
className="gap-0 space-y-2 shadow-none sm:space-y-3"
|
className="gap-0 space-y-2 shadow-none sm:space-y-3"
|
||||||
value={selectedAssistantRecipient?.id?.toString()}
|
value={selectedAssistantRecipient?.id?.toString()}
|
||||||
@@ -54,7 +54,7 @@ export default function EnvelopeSignerForm() {
|
|||||||
.map((r) => (
|
.map((r) => (
|
||||||
<div
|
<div
|
||||||
key={r.id}
|
key={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 justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -69,15 +69,15 @@ export default function EnvelopeSignerForm() {
|
|||||||
{r.name}
|
{r.name}
|
||||||
|
|
||||||
{r.id === recipient.id && (
|
{r.id === recipient.id && (
|
||||||
<span className="text-muted-foreground ml-2">
|
<span className="ml-2 text-muted-foreground">
|
||||||
<Trans>(You)</Trans>
|
<Trans>(You)</Trans>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
<p className="text-xs text-muted-foreground">{r.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
<div className="text-xs leading-[inherit] text-muted-foreground">
|
||||||
<Plural
|
<Plural
|
||||||
value={assistantFields.filter((field) => field.recipientId === r.id).length}
|
value={assistantFields.filter((field) => field.recipientId === r.id).length}
|
||||||
one="# field"
|
one="# field"
|
||||||
@@ -103,7 +103,7 @@ export default function EnvelopeSignerForm() {
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="full-name"
|
id="full-name"
|
||||||
className="bg-background mt-2"
|
className="mt-2 bg-background"
|
||||||
value={fullName}
|
value={fullName}
|
||||||
disabled={isNameLocked}
|
disabled={isNameLocked}
|
||||||
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
|
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
|
||||||
@@ -119,6 +119,7 @@ export default function EnvelopeSignerForm() {
|
|||||||
<SignaturePadDialog
|
<SignaturePadDialog
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
fullName={fullName}
|
||||||
value={signature ?? ''}
|
value={signature ?? ''}
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
onChange={(v) => setSignature(v ?? '')}
|
||||||
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
|
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
.with({ type: FieldType.SIGNATURE }, (field) => {
|
.with({ type: FieldType.SIGNATURE }, (field) => {
|
||||||
handleSignatureFieldClick({
|
handleSignatureFieldClick({
|
||||||
field,
|
field,
|
||||||
|
fullName,
|
||||||
signature,
|
signature,
|
||||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
||||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
||||||
|
|||||||
@@ -80,12 +80,14 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
const handleOnCompleteClick = async (
|
const handleOnCompleteClick = async (
|
||||||
nextSigner?: { name: string; email: string },
|
nextSigner?: { name: string; email: string },
|
||||||
accessAuthOptions?: TRecipientAccessAuth,
|
accessAuthOptions?: TRecipientAccessAuth,
|
||||||
|
recipientDetails?: { name: string; email: string },
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await completeDocument({
|
await completeDocument({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
accessAuthOptions,
|
accessAuthOptions,
|
||||||
|
recipientOverride: recipientDetails,
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -205,21 +207,30 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const directTemplatePayload = useMemo(() => {
|
const recipientPayload = useMemo(() => {
|
||||||
if (!isDirectTemplate) {
|
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 {
|
return {
|
||||||
name: fullName,
|
name: fullName,
|
||||||
email: email,
|
email: email,
|
||||||
};
|
};
|
||||||
}, [email, fullName, isDirectTemplate]);
|
}, [email, fullName, isDirectTemplate, recipient.email, recipient.name, recipient.fields]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningCompleteDialog
|
<DocumentSigningCompleteDialog
|
||||||
isSubmitting={isPending}
|
isSubmitting={isPending}
|
||||||
directTemplatePayload={directTemplatePayload}
|
recipientPayload={recipientPayload}
|
||||||
onSignatureComplete={
|
onSignatureComplete={
|
||||||
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
|
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
|
||||||
}
|
}
|
||||||
@@ -230,6 +241,7 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
allowDictateNextSigner={Boolean(
|
allowDictateNextSigner={Boolean(
|
||||||
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
|
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
|
||||||
)}
|
)}
|
||||||
|
disableNameInput={!isDirectTemplate && recipient.name !== ''}
|
||||||
defaultNextSigner={
|
defaultNextSigner={
|
||||||
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
|
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,14 +123,14 @@ export const EnvelopeDropZoneWrapper = ({
|
|||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
const errorMessage = match(error.code)
|
const errorMessage = match(error.code)
|
||||||
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
|
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
|
||||||
.with(
|
.with(
|
||||||
AppErrorCode.LIMIT_EXCEEDED,
|
AppErrorCode.LIMIT_EXCEEDED,
|
||||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||||
() => t`You have reached the limit of the number of files per envelope`,
|
() => t`You have reached the limit of the number of files per envelope.`,
|
||||||
)
|
)
|
||||||
.otherwise(() => t`An error occurred during upload.`);
|
.otherwise(() => t`An error occurred during upload.`);
|
||||||
|
|
||||||
|
|||||||
@@ -126,14 +126,14 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
const errorMessage = match(error.code)
|
const errorMessage = match(error.code)
|
||||||
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
|
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
|
||||||
.with(
|
.with(
|
||||||
AppErrorCode.LIMIT_EXCEEDED,
|
AppErrorCode.LIMIT_EXCEEDED,
|
||||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||||
() => t`You have reached the limit of the number of files per envelope`,
|
() => t`You have reached the limit of the number of files per envelope.`,
|
||||||
)
|
)
|
||||||
.otherwise(() => t`An error occurred while uploading your document.`);
|
.otherwise(() => t`An error occurred while uploading your document.`);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
|
|||||||
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||||
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
||||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
|
||||||
import { DocumentUploadButtonLegacy } from '~/components/general/document/document-upload-button-legacy';
|
import { DocumentUploadButtonLegacy } from '~/components/general/document/document-upload-button-legacy';
|
||||||
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
@@ -70,7 +69,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
<div
|
<div
|
||||||
className="text-muted-foreground hover:text-muted-foreground/80 flex flex-1 items-center text-sm font-medium"
|
className="flex flex-1 items-center text-sm font-medium text-muted-foreground hover:text-muted-foreground/80"
|
||||||
data-testid="folder-grid-breadcrumbs"
|
data-testid="folder-grid-breadcrumbs"
|
||||||
>
|
>
|
||||||
<Link to={formatRootPath()} className="flex items-center">
|
<Link to={formatRootPath()} className="flex items-center">
|
||||||
@@ -100,10 +99,9 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||||
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
|
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
|
||||||
|
|
||||||
{type === FolderType.DOCUMENT ? (
|
{/* If you delete this, delete the component as well. */}
|
||||||
<DocumentUploadButtonLegacy /> // If you delete this, delete the component as well.
|
{organisation.organisationClaim.flags.allowLegacyEnvelopes && (
|
||||||
) : (
|
<DocumentUploadButtonLegacy type={type} />
|
||||||
<TemplateCreateDialog folderId={parentId ?? undefined} /> // If you delete this, delete the component as well.
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FolderCreateDialog type={type} />
|
<FolderCreateDialog type={type} />
|
||||||
@@ -113,7 +111,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
<div key={index} className="border-border bg-card h-full rounded-lg border px-4 py-5">
|
<div key={index} className="h-full rounded-lg border border-border bg-card px-4 py-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="h-8 w-8 rounded" />
|
<Skeleton className="h-8 w-8 rounded" />
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
@@ -194,7 +192,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
{foldersData.folders.length > 12 && (
|
{foldersData.folders.length > 12 && (
|
||||||
<div className="mt-2 flex items-center justify-center">
|
<div className="mt-2 flex items-center justify-center">
|
||||||
<Link
|
<Link
|
||||||
className="text-muted-foreground hover:text-foreground text-sm font-medium"
|
className="text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||||
to={formatViewAllFoldersPath()}
|
to={formatViewAllFoldersPath()}
|
||||||
>
|
>
|
||||||
View all folders
|
View all folders
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
<p className="text-xs text-muted-foreground/70">
|
||||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,8 +107,8 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
|
||||||
<p className="text-muted-foreground/70 text-xs">
|
<p className="text-xs text-muted-foreground/70">
|
||||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
|
|||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { Role, Subscription } from '@prisma/client';
|
import type { Role, Subscription } from '@prisma/client';
|
||||||
import { Edit, Loader } from 'lucide-react';
|
import { Edit, Loader } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
@@ -82,7 +83,7 @@ export const AdminDashboardUsersTable = ({
|
|||||||
<Button className="w-24" asChild>
|
<Button className="w-24" asChild>
|
||||||
<Link to={`/admin/users/${row.original.id}`}>
|
<Link to={`/admin/users/${row.original.id}`}>
|
||||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Edit
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { TableCell } from '@documenso/ui/primitives/table';
|
|||||||
|
|
||||||
export type DocumentLogsTableProps = {
|
export type DocumentLogsTableProps = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
userId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateFormat: DateTimeFormatOptions = {
|
const dateFormat: DateTimeFormatOptions = {
|
||||||
@@ -26,7 +27,7 @@ const dateFormat: DateTimeFormatOptions = {
|
|||||||
hourCycle: 'h12',
|
hourCycle: 'h12',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
|
export const DocumentLogsTable = ({ documentId, userId }: DocumentLogsTableProps) => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -93,7 +94,9 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
|
|||||||
{
|
{
|
||||||
header: _(msg`Action`),
|
header: _(msg`Action`),
|
||||||
accessorKey: 'type',
|
accessorKey: 'type',
|
||||||
cell: ({ row }) => <span>{formatDocumentAuditLogAction(_, row.original).description}</span>,
|
cell: ({ row }) => (
|
||||||
|
<span>{formatDocumentAuditLogAction(_, row.original, userId).description}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: _(msg`IP Address`),
|
header: _(msg`IP Address`),
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ export const OrganisationGroupsDataTable = () => {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-2">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link to={`/o/${organisation.url}/settings/groups/${row.original.id}`}>Manage</Link>
|
<Link to={`/o/${organisation.url}/settings/groups/${row.original.id}`}>
|
||||||
|
<Trans>Manage</Trans>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<OrganisationGroupDeleteDialog
|
<OrganisationGroupDeleteDialog
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { HydratedRouter } from 'react-router/dom';
|
|||||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||||
|
|
||||||
|
import './utils/polyfills/promise-with-resolvers';
|
||||||
|
|
||||||
function PosthogInit() {
|
function PosthogInit() {
|
||||||
const postHogConfig = extractPostHogConfig();
|
const postHogConfig = extractPostHogConfig();
|
||||||
|
|
||||||
|
|||||||
@@ -46,12 +46,16 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
const { getTheme } = await themeSessionResolver(request);
|
const { getTheme } = await themeSessionResolver(request);
|
||||||
|
|
||||||
let lang: SupportedLanguageCodes = await langCookie.parse(request.headers.get('cookie') ?? '');
|
const cookieHeader = request.headers.get('cookie') ?? '';
|
||||||
|
|
||||||
|
let lang: SupportedLanguageCodes = await langCookie.parse(cookieHeader);
|
||||||
|
|
||||||
if (!APP_I18N_OPTIONS.supportedLangs.includes(lang)) {
|
if (!APP_I18N_OPTIONS.supportedLangs.includes(lang)) {
|
||||||
lang = extractLocaleData({ headers: request.headers }).lang;
|
lang = extractLocaleData({ headers: request.headers }).lang;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const disableAnimations = cookieHeader.includes('__disable_animations=true');
|
||||||
|
|
||||||
let organisations = null;
|
let organisations = null;
|
||||||
|
|
||||||
if (session.isAuthenticated) {
|
if (session.isAuthenticated) {
|
||||||
@@ -62,6 +66,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
{
|
{
|
||||||
lang,
|
lang,
|
||||||
theme: getTheme(),
|
theme: getTheme(),
|
||||||
|
disableAnimations,
|
||||||
session: session.isAuthenticated
|
session: session.isAuthenticated
|
||||||
? {
|
? {
|
||||||
user: session.user,
|
user: session.user,
|
||||||
@@ -92,7 +97,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutContent({ children }: { children: React.ReactNode }) {
|
export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||||
const { publicEnv, session, lang, ...data } = useLoaderData<typeof loader>() || {};
|
const { publicEnv, session, lang, disableAnimations, ...data } =
|
||||||
|
useLoaderData<typeof loader>() || {};
|
||||||
|
|
||||||
const [theme] = useTheme();
|
const [theme] = useTheme();
|
||||||
|
|
||||||
@@ -111,6 +117,14 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
|
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
|
||||||
|
|
||||||
|
{disableAnimations && (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
|
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
|
||||||
<script>0</script>
|
<script>0</script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType, SigningStatus } from '@prisma/client';
|
import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-4 text-sm">
|
<div className="mt-4 text-sm text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
|
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +112,8 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
|||||||
disabled={envelope.recipients.some(
|
disabled={envelope.recipients.some(
|
||||||
(recipient) =>
|
(recipient) =>
|
||||||
recipient.signingStatus !== SigningStatus.SIGNED &&
|
recipient.signingStatus !== SigningStatus.SIGNED &&
|
||||||
recipient.signingStatus !== SigningStatus.REJECTED,
|
recipient.signingStatus !== SigningStatus.REJECTED &&
|
||||||
|
recipient.role !== RecipientRole.CC,
|
||||||
)}
|
)}
|
||||||
onClick={() => resealDocument({ id: envelope.id })}
|
onClick={() => resealDocument({ id: envelope.id })}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -120,7 +120,9 @@ export default function OrganisationSettingsTeamsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to={`/o/${organisation.url}/settings`}>Manage Organisation</Link>
|
<Link to={`/o/${organisation.url}/settings`}>
|
||||||
|
<Trans>Manage Organisation</Trans>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,7 +180,9 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
<MoreVerticalIcon className="h-4 w-4" />
|
<MoreVerticalIcon className="h-4 w-4" />
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ export default function OrganisationSettingsBrandingPage() {
|
|||||||
const settingsHeaderText = t`Branding Preferences`;
|
const settingsHeaderText = t`Branding Preferences`;
|
||||||
|
|
||||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
const settingsHeaderSubtitle = isPersonalLayoutMode
|
||||||
? t`Here you can set your general branding preferences`
|
? t`Here you can set your general branding preferences.`
|
||||||
: team
|
: team
|
||||||
? t`Here you can set branding preferences for your team`
|
? t`Here you can set branding preferences for your team.`
|
||||||
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
|
delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
delegateDocumentOwnership: delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -112,7 +114,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
|
|
||||||
const settingsHeaderText = t`Document Preferences`;
|
const settingsHeaderText = t`Document Preferences`;
|
||||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
const settingsHeaderSubtitle = isPersonalLayoutMode
|
||||||
? t`Here you can set your general document preferences`
|
? t`Here you can set your general document preferences.`
|
||||||
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
|
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function OrganisationSettingsGeneral() {
|
|||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={t`Email Preferences`}
|
title={t`Email Preferences`}
|
||||||
subtitle={t`You can manage your email preferences here`}
|
subtitle={t`You can manage your email preferences here.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import DocumentPage, { meta } from '../../o.$orgUrl.settings.document';
|
import DocumentPage, { loader, meta } from '../../o.$orgUrl.settings.document';
|
||||||
|
|
||||||
export { meta };
|
export { meta, loader };
|
||||||
|
|
||||||
export default DocumentPage;
|
export default DocumentPage;
|
||||||
|
|||||||
@@ -75,11 +75,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
},
|
},
|
||||||
recipients: envelope.recipients,
|
recipients: envelope.recipients,
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
|
userId: user.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { document, recipients, documentRootPath } = loaderData;
|
const { document, recipients, documentRootPath, userId } = loaderData;
|
||||||
|
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
@@ -171,15 +172,15 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
|
||||||
{documentInformation.map((info, i) => (
|
{documentInformation.map((info, i) => (
|
||||||
<div className="text-foreground text-sm" key={i}>
|
<div className="text-sm text-foreground" key={i}>
|
||||||
<h3 className="font-semibold">{_(info.description)}</h3>
|
<h3 className="font-semibold">{_(info.description)}</h3>
|
||||||
<p className="text-muted-foreground truncate">{info.value}</p>
|
<p className="truncate text-muted-foreground">{info.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="text-foreground text-sm">
|
<div className="text-sm text-foreground">
|
||||||
<h3 className="font-semibold">Recipients</h3>
|
<h3 className="font-semibold">Recipients</h3>
|
||||||
<ul className="text-muted-foreground list-inside list-disc">
|
<ul className="list-inside list-disc text-muted-foreground">
|
||||||
{recipients.map((recipient) => (
|
{recipients.map((recipient) => (
|
||||||
<li key={`recipient-${recipient.id}`}>
|
<li key={`recipient-${recipient.id}`}>
|
||||||
<span>{formatRecipientText(recipient)}</span>
|
<span>{formatRecipientText(recipient)}</span>
|
||||||
@@ -191,7 +192,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
<DocumentLogsTable documentId={document.id} />
|
<DocumentLogsTable documentId={document.id} userId={userId} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ export const loader = () => {
|
|||||||
export default function TeamsSettingsPage() {
|
export default function TeamsSettingsPage() {
|
||||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
console.log('isAiFeaturesConfigured', isAiFeaturesConfigured);
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
@@ -52,6 +50,7 @@ export default function TeamsSettingsPage() {
|
|||||||
includeSigningCertificate,
|
includeSigningCertificate,
|
||||||
includeAuditLog,
|
includeAuditLog,
|
||||||
signatureTypes,
|
signatureTypes,
|
||||||
|
delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
@@ -77,6 +76,7 @@ export default function TeamsSettingsPage() {
|
|||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
}),
|
}),
|
||||||
|
delegateDocumentOwnership: delegateDocumentOwnership,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||