mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 20:32:07 +10:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bac2bf11f4 | |||
| d93b2a70a7 | |||
| 5da915da38 | |||
| dcaecf1fc5 | |||
| f70b76d8b8 | |||
| 93137c6396 | |||
| d058b7c705 | |||
| b51f562224 | |||
| f80aa4bf72 | |||
| 9238f759a6 | |||
| 74ad6af47d | |||
| 18902ed59d | |||
| 3f70082146 | |||
| 31ba6d5f00 | |||
| c4f89a87a2 | |||
| 89d6dd5b0e | |||
| 08a9ab3aaf | |||
| e66bd422e3 | |||
| 0f5814ff89 | |||
| 1275a15571 | |||
| 22d99c7410 | |||
| 26a36487d4 | |||
| 2ee6b90c99 | |||
| f70e6ac50a | |||
| 7a94ee3b83 | |||
| e39924714a | |||
| c9604fee64 | |||
| 90f8340af4 | |||
| 28b8d2d415 | |||
| 978a2047d4 | |||
| 0dfa953f54 | |||
| 4774324e07 | |||
| bc19699a58 | |||
| 55480826de | |||
| 327b0eaf86 | |||
| 2de5c1992f | |||
| df0c03816e | |||
| a610a06372 | |||
| d5e085d7ee | |||
| c322356654 |
+10
-1
@@ -147,6 +147,15 @@ NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
|
||||
# We only collect: app version, installation ID, and node ID. No personal data is collected.
|
||||
DOCUMENSO_DISABLE_TELEMETRY=
|
||||
|
||||
# [[AI]]
|
||||
# OPTIONAL: Google Cloud Project ID for Vertex AI.
|
||||
GOOGLE_VERTEX_PROJECT_ID=""
|
||||
# OPTIONAL: Google Cloud region for Vertex AI. Defaults to "global".
|
||||
GOOGLE_VERTEX_LOCATION="global"
|
||||
# OPTIONAL: API key for Google Vertex AI (Gemini). Get your key from:
|
||||
# https://console.cloud.google.com/vertex-ai/studio/settings/api-keys
|
||||
GOOGLE_VERTEX_API_KEY=""
|
||||
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
@@ -157,4 +166,4 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
NEXT_PRIVATE_LOGGER_FILE_PATH=
|
||||
|
||||
# [[PLAIN SUPPORT]]
|
||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: ['main', 'feat/rr7']
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e_tests:
|
||||
name: 'E2E Tests'
|
||||
timeout-minutes: 60
|
||||
runs-on: warp-ubuntu-2204-x64-16x
|
||||
runs-on: warp-ubuntu-2204-x64-8x
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -28,9 +33,6 @@ jobs:
|
||||
- name: Seed the database
|
||||
run: npm run prisma:seed
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
|
||||
- name: Install playwright browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
@@ -45,7 +47,7 @@ jobs:
|
||||
with:
|
||||
name: test-results
|
||||
path: 'packages/app-tests/**/test-results/*'
|
||||
retention-days: 30
|
||||
retention-days: 7
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
@@ -17,6 +17,7 @@ jobs:
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -26,12 +27,54 @@ jobs:
|
||||
- name: Extract translations
|
||||
run: npm run translate:extract
|
||||
|
||||
- name: Check and commit any files created
|
||||
- name: Commit changes and push to reserved branch
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="chore/extract-translations"
|
||||
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@documenso.com'
|
||||
|
||||
git fetch origin
|
||||
|
||||
# Create branch locally (always reset to main)
|
||||
git checkout -B "$BRANCH" origin/main
|
||||
|
||||
# Stage translation output
|
||||
git add packages/lib/translations
|
||||
git diff --staged --quiet --exit-code || (git commit -m "chore: extract translations" && git push)
|
||||
|
||||
# If no changes, exit early
|
||||
if git diff --staged --quiet; then
|
||||
echo "No translation changes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Commit fresh snapshot
|
||||
git commit -m "chore: extract translations"
|
||||
|
||||
# Force push reserved branch
|
||||
git push origin "$BRANCH" --force
|
||||
|
||||
# Does a PR already exist?
|
||||
EXISTING_PR=$(gh pr list \
|
||||
--state open \
|
||||
--head "$BRANCH" \
|
||||
--json number \
|
||||
--jq '.[0].number // empty')
|
||||
|
||||
if [ -z "$EXISTING_PR" ]; then
|
||||
echo "No existing PR — creating new one."
|
||||
gh pr create \
|
||||
--title "chore: extract translations" \
|
||||
--body "Automated translation extraction" \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
else
|
||||
echo "PR #$EXISTING_PR already exists — not creating a new one."
|
||||
fi
|
||||
|
||||
- name: Compile translations
|
||||
id: compile_translations
|
||||
|
||||
@@ -60,3 +60,6 @@ CLAUDE.md
|
||||
|
||||
# agents
|
||||
.specs
|
||||
|
||||
# scripts
|
||||
scripts/output*
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
> 🚨 🚨 🚨
|
||||
> Documenso 2.0.0 is live on Product Hunt 🎉 <a href="https://documen.so/launch" target="_blank" rel="noopener noreferrer" style="text-decoration: underline;">Join us to celebrate the best Documenso yet 🪩</a>
|
||||
|
||||
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||
|
||||
<p align="center" style="margin-top: 20px">
|
||||
@@ -174,9 +171,11 @@ git clone https://github.com/<your-username>/documenso
|
||||
|
||||
5. Create the database schema by running `npm run prisma:migrate-dev`
|
||||
|
||||
6. Run `npm run dev` in the root directory to start
|
||||
6. Run `npm run translate:compile` in the root directory to compile lingui
|
||||
|
||||
7. Register a new user at http://localhost:3000/signup
|
||||
7. Run `npm run dev` in the root directory to start
|
||||
|
||||
8. Register a new user at http://localhost:3000/signup
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"next": "^15",
|
||||
"next": "^15.5.7",
|
||||
"next-plausible": "^3.12.5",
|
||||
"nextra": "^3",
|
||||
"nextra-theme-docs": "^3",
|
||||
|
||||
@@ -4,4 +4,5 @@ export default {
|
||||
'how-to': 'How To',
|
||||
'setting-up-oauth-providers': 'Setting up OAuth Providers',
|
||||
telemetry: 'Telemetry',
|
||||
'ai-features': 'AI Recipient & Field Detection',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: AI Recipient & Field Detection (Self-hosting)
|
||||
description: Configure Google Vertex AI so Documenso can detect recipients and fields automatically.
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# AI Recipient & Field Detection (Self-hosting)
|
||||
|
||||
This guide covers how to enable the AI recipient and field detection features when you self-host Documenso.
|
||||
|
||||
## What this enables
|
||||
|
||||
- Detect recipients from uploaded PDFs (roles, names, emails when present).
|
||||
- Detect and place fields (signature, initials, name, email, date, text, number, radio, checkbox) onto draft envelopes.
|
||||
- Built-in rate limits (3 requests per minute per IP) to prevent abuse.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Google Cloud project with the **Vertex AI API** enabled and billing active.
|
||||
- A **Vertex AI Express API key** with access to Gemini models (create via the [Vertex AI Express flow](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview) and manage keys in [API keys](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys)).
|
||||
- Documenso version that includes the AI detection feature and the corresponding database migration.
|
||||
|
||||
## Configure environment variables
|
||||
|
||||
Add these variables to your deployment `.env` (or secret manager):
|
||||
|
||||
```
|
||||
GOOGLE_VERTEX_PROJECT_ID="<your-gcp-project-id>"
|
||||
GOOGLE_VERTEX_API_KEY="<your-vertex-api-key>"
|
||||
# Optional, defaults to "global"
|
||||
GOOGLE_VERTEX_LOCATION="global"
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Use a region close to your users if you need data residency considerations (e.g. `europe-west1`).
|
||||
If you omit the location, Documenso uses `global`. Not all models are available in every region;
|
||||
if a model is unavailable, switch to a supported region.
|
||||
</Callout>
|
||||
|
||||
## Deploy with the published container
|
||||
|
||||
- Use the official Documenso image (DockerHub or GHCR) and supply the Vertex env vars above.
|
||||
- Ensure migrations run on startup (the container runs `prisma migrate deploy` in production mode).
|
||||
- Restart the container after adding or changing Vertex env vars.
|
||||
|
||||
## Enable the feature in Documenso
|
||||
|
||||
Once the service is running with the Vertex env vars:
|
||||
|
||||
<Steps>
|
||||
### Organisation settings
|
||||
|
||||
Go to **Settings → Document Preferences → AI Features** and set to **Enabled**. Teams that inherit organisation defaults will pick this up.
|
||||
|
||||
### Team settings
|
||||
|
||||
If a team overrides organisation defaults, go to **Team Settings → Document Preferences → AI Features** and choose **Enabled** (or **Inherit** to follow the organisation).
|
||||
|
||||
### Verify in the editor
|
||||
|
||||
Open a draft envelope. In **Recipients**, you should see the sparkle button for AI detection. In **Fields**, you should see **Detect with AI** available.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Too many requests**: Wait a minute or two and retry (rate limit is 3/min per IP).
|
||||
- **AI options hidden**: Ensure the env vars are set, the server was restarted after setting them, and `aiFeaturesEnabled` is enabled at organisation/team level.
|
||||
- **Detection fails immediately**: Confirm the Vertex API key is valid and the project has Vertex AI enabled. Check server logs for status codes from Vertex.
|
||||
|
||||
If issues persist, recheck env vars, restart the service, and confirm the Prisma migration was applied.
|
||||
@@ -119,6 +119,8 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
||||
```
|
||||
|
||||
For full AI setup details (including model availability notes), see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
|
||||
|
||||
### Set Up Your Signing Certificate
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -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.
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
||||
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
|
||||
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
||||
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
|
||||
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
|
||||
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
|
||||
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
|
||||
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
||||
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
|
||||
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
|
||||
For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
||||
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
|
||||
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
||||
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
|
||||
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
|
||||
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
|
||||
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
|
||||
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
||||
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
|
||||
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
|
||||
| `GOOGLE_VERTEX_PROJECT_ID` | Google Cloud project ID used for Vertex AI (required for AI detection). |
|
||||
| `GOOGLE_VERTEX_API_KEY` | Vertex AI Express API key with access to Gemini models (required for AI detection). See [AI Recipient & Field Detectionfor](./ai-features) for details. |
|
||||
| `GOOGLE_VERTEX_LOCATION` | Optional Vertex region, defaults to `global`. Not all models are available in every region. |
|
||||
|
||||
## Telemetry
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ export default {
|
||||
'document-visibility': 'Document Visibility',
|
||||
fields: 'Document Fields',
|
||||
'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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 370 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "^15"
|
||||
"next": "^15.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
@@ -45,7 +45,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
||||
|
||||
toast({
|
||||
title: _(msg`Document deleted`),
|
||||
description: 'The Document has been deleted successfully.',
|
||||
description: _(msg`The Document has been deleted successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -54,8 +54,9 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to delete your document. Please try again later.',
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to delete your document. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
|
||||
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
type DetectFieldsProgressEvent,
|
||||
detectFields,
|
||||
} from '../../../server/api/ai/detect-fields.client';
|
||||
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
|
||||
|
||||
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
|
||||
|
||||
type AiFieldDetectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onComplete: (fields: NormalizedFieldWithContext[]) => void;
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const PROCESSING_MESSAGES = [
|
||||
msg`Reading your document`,
|
||||
msg`Analyzing page layout`,
|
||||
msg`Looking for form fields`,
|
||||
msg`Detecting signature areas`,
|
||||
msg`Identifying input fields`,
|
||||
msg`Mapping fields to recipients`,
|
||||
msg`Almost done`,
|
||||
] as const;
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<string, MessageDescriptor> = {
|
||||
SIGNATURE: msg`Signature`,
|
||||
INITIALS: msg`Initials`,
|
||||
NAME: msg`Name`,
|
||||
EMAIL: msg`Email`,
|
||||
DATE: msg`Date`,
|
||||
TEXT: msg`Text`,
|
||||
NUMBER: msg`Number`,
|
||||
CHECKBOX: msg`Checkbox`,
|
||||
RADIO: msg`Radio`,
|
||||
};
|
||||
|
||||
export const AiFieldDetectionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onComplete,
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: AiFieldDetectionDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [state, setState] = useState<DialogState>('PROMPT');
|
||||
const [messageIndex, setMessageIndex] = useState(0);
|
||||
const [detectedFields, setDetectedFields] = useState<NormalizedFieldWithContext[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [context, setContext] = useState('');
|
||||
const [progress, setProgress] = useState<DetectFieldsProgressEvent | null>(null);
|
||||
|
||||
const onDetectClick = useCallback(async () => {
|
||||
setState('PROCESSING');
|
||||
setMessageIndex(0);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
await detectFields({
|
||||
request: {
|
||||
envelopeId,
|
||||
teamId,
|
||||
context: context || undefined,
|
||||
},
|
||||
onProgress: (progressEvent) => {
|
||||
setProgress(progressEvent);
|
||||
},
|
||||
onComplete: (event) => {
|
||||
setDetectedFields(event.fields);
|
||||
setState('REVIEW');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err instanceof AiApiError && err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect fields');
|
||||
setState('ERROR');
|
||||
}
|
||||
}, [envelopeId, teamId, context]);
|
||||
|
||||
const onAddFields = () => {
|
||||
onComplete(detectedFields);
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedFields([]);
|
||||
setContext('');
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedFields([]);
|
||||
setError(null);
|
||||
setContext('');
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
// Group fields by type for summary display
|
||||
const fieldCountsByType = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const field of detectedFields) {
|
||||
counts[field.type] = (counts[field.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return Object.entries(counts).sort(([, a], [, b]) => b - a);
|
||||
}, [detectedFields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-lg" hideClose={true}>
|
||||
{state === 'PROMPT' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detect fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We'll scan your document to find form fields like signature lines, text inputs,
|
||||
checkboxes, and more. Detected fields will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="context">
|
||||
<Trans>Context</Trans>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="context"
|
||||
placeholder={_(msg`David is the Employee, Lucas is the Manager`)}
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>Help the AI assign fields to the right recipients.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Detect</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'PROCESSING' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detecting fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<AnimatedDocumentScanner />
|
||||
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.fieldsDetected} field(s) found
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-1">
|
||||
{PROCESSING_MESSAGES.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
|
||||
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'REVIEW' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detected fields</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{detectedFields.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<FormInputIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No fields were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<Trans>You can add fields manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>We found {detectedFields.length} field(s) in your document.</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
{fieldCountsByType.map(([type, count]) => (
|
||||
<li key={type} className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm">{_(FIELD_TYPE_LABELS[type]) || type}</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">{count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{detectedFields.length > 0 && (
|
||||
<Button type="button" onClick={onAddFields}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Add fields</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detection failed</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Something went wrong while detecting fields.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'RATE_LIMITED' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Too many requests</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import {
|
||||
AiApiError,
|
||||
type DetectRecipientsProgressEvent,
|
||||
detectRecipients,
|
||||
} from '../../../server/api/ai/detect-recipients.client';
|
||||
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
|
||||
|
||||
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
|
||||
|
||||
type AiRecipientDetectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onComplete: (recipients: TDetectedRecipientSchema[]) => void;
|
||||
envelopeId: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const PROCESSING_MESSAGES = [
|
||||
msg`Reading your document`,
|
||||
msg`Analyzing pages`,
|
||||
msg`Looking for signature fields`,
|
||||
msg`Identifying recipients`,
|
||||
msg`Extracting contact details`,
|
||||
msg`Almost done`,
|
||||
] as const;
|
||||
|
||||
export const AiRecipientDetectionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onComplete,
|
||||
envelopeId,
|
||||
teamId,
|
||||
}: AiRecipientDetectionDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [state, setState] = useState<DialogState>('PROMPT');
|
||||
const [messageIndex, setMessageIndex] = useState(0);
|
||||
const [detectedRecipients, setDetectedRecipients] = useState<TDetectedRecipientSchema[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<DetectRecipientsProgressEvent | null>(null);
|
||||
|
||||
const onDetectClick = useCallback(async () => {
|
||||
setState('PROCESSING');
|
||||
setMessageIndex(0);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
await detectRecipients({
|
||||
request: {
|
||||
envelopeId,
|
||||
teamId,
|
||||
},
|
||||
onProgress: (progressEvent) => {
|
||||
setProgress(progressEvent);
|
||||
},
|
||||
onComplete: (event) => {
|
||||
setDetectedRecipients(event.recipients);
|
||||
setState('REVIEW');
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err.message);
|
||||
setState('ERROR');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Detection failed:', err);
|
||||
|
||||
if (err instanceof AiApiError && err.status === 429) {
|
||||
setState('RATE_LIMITED');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to detect recipients');
|
||||
setState('ERROR');
|
||||
}
|
||||
}, [envelopeId, teamId]);
|
||||
|
||||
const handleRemoveRecipient = (index: number) => {
|
||||
setDetectedRecipients((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const onAddRecipients = () => {
|
||||
onComplete(detectedRecipients);
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedRecipients([]);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
onOpenChange(false);
|
||||
setState('PROMPT');
|
||||
setDetectedRecipients([]);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'PROCESSING') {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-lg" hideClose={true}>
|
||||
{state === 'PROMPT' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detect recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We'll scan your document to find signature fields and identify who needs to sign.
|
||||
Detected recipients will be suggested for you to review.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
|
||||
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
|
||||
<AlertDescription className="mt-0">
|
||||
<Trans>
|
||||
Your document is processed securely using AI services that don't retain your
|
||||
data.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Skip</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Detect</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'PROCESSING' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detecting recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<AnimatedDocumentScanner />
|
||||
|
||||
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.recipientsDetected} recipient(s) found
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
|
||||
<Trans>This can take a minute or two depending on the size of your document.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-1">
|
||||
{PROCESSING_MESSAGES.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
|
||||
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'REVIEW' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detected recipients</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{detectedRecipients.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<UserIcon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No recipients were detected in your document.</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground/70">
|
||||
<Trans>You can add recipients manually in the editor.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We found {detectedRecipients.length} recipient(s) in your document.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
{detectedRecipients.map((recipient, index) => (
|
||||
<li key={index} className="flex items-center justify-between px-4 py-3">
|
||||
<AvatarWithText
|
||||
avatarFallback={
|
||||
recipient.name
|
||||
? recipient.name.slice(0, 1).toUpperCase()
|
||||
: recipient.email
|
||||
? recipient.email.slice(0, 1).toUpperCase()
|
||||
: '?'
|
||||
}
|
||||
primaryText={
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{recipient.name || _(msg`Unknown name`)}
|
||||
</p>
|
||||
}
|
||||
secondaryText={
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p className="italic text-muted-foreground/70">
|
||||
{recipient.email || _(msg`No email detected`)}
|
||||
</p>
|
||||
<p>{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 p-0 text-muted-foreground/80 hover:text-destructive focus-visible:border-destructive focus-visible:ring-destructive"
|
||||
onClick={() => handleRemoveRecipient(index)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
<Trans>Remove recipient</Trans>
|
||||
</span>
|
||||
|
||||
<XIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
{detectedRecipients.length > 0 && (
|
||||
<Button type="button" onClick={onAddRecipients}>
|
||||
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Add recipients</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'ERROR' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Detection failed</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Something went wrong while detecting recipients.</Trans>
|
||||
</p>
|
||||
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === 'RATE_LIMITED' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Too many requests</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
You've made too many detection requests. Please wait a minute before trying again.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
<Button type="button" onClick={onDetectClick}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
DocumentDistributionMethod,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
type Field,
|
||||
FieldType,
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
} from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
@@ -19,8 +17,9 @@ import { useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -52,16 +51,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type EnvelopeDistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
recipients: Recipient[];
|
||||
fields: Pick<Field, 'type' | 'recipientId'>[];
|
||||
};
|
||||
onDistribute?: () => Promise<void>;
|
||||
documentRootPath: string;
|
||||
trigger?: React.ReactNode;
|
||||
@@ -86,20 +82,20 @@ export const ZEnvelopeDistributeFormSchema = z.object({
|
||||
export type TEnvelopeDistributeFormSchema = z.infer<typeof ZEnvelopeDistributeFormSchema>;
|
||||
|
||||
export const EnvelopeDistributeDialog = ({
|
||||
envelope,
|
||||
trigger,
|
||||
documentRootPath,
|
||||
onDistribute,
|
||||
}: EnvelopeDistributeDialogProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const recipients = envelope.recipients;
|
||||
const { envelope, syncEnvelope, isAutosaving, autosaveError } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const { mutateAsync: distributeEnvelope } = trpcReact.envelope.distribute.useMutation();
|
||||
|
||||
@@ -134,18 +130,43 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
const distributionMethod = watch('meta.distributionMethod');
|
||||
|
||||
const recipientsWithIndex = useMemo(
|
||||
() =>
|
||||
envelope.recipients.map((recipient, index) => ({
|
||||
...recipient,
|
||||
index,
|
||||
})),
|
||||
[envelope.recipients],
|
||||
);
|
||||
|
||||
const recipientsMissingSignatureFields = useMemo(
|
||||
() =>
|
||||
envelope.recipients.filter(
|
||||
recipientsWithIndex.filter(
|
||||
(recipient) =>
|
||||
recipient.role === RecipientRole.SIGNER &&
|
||||
!envelope.fields.some(
|
||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||
),
|
||||
),
|
||||
[envelope.recipients, envelope.fields],
|
||||
[recipientsWithIndex, envelope.fields],
|
||||
);
|
||||
|
||||
/**
|
||||
* List of recipients who must have an email due to having auth enabled.
|
||||
*/
|
||||
const recipientsMissingRequiredEmail = useMemo(() => {
|
||||
return recipientsWithIndex.filter((recipient) => {
|
||||
const auth = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
return (
|
||||
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email
|
||||
);
|
||||
});
|
||||
}, [recipientsWithIndex, envelope.authOptions]);
|
||||
|
||||
const invalidEnvelopeCode = useMemo(() => {
|
||||
if (recipientsMissingSignatureFields.length > 0) {
|
||||
return 'MISSING_SIGNATURES';
|
||||
@@ -155,8 +176,12 @@ export const EnvelopeDistributeDialog = ({
|
||||
return 'MISSING_RECIPIENTS';
|
||||
}
|
||||
|
||||
if (recipientsMissingRequiredEmail.length > 0) {
|
||||
return 'MISSING_REQUIRED_EMAIL';
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
|
||||
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
|
||||
|
||||
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
||||
try {
|
||||
@@ -189,6 +214,29 @@ export const EnvelopeDistributeDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
if (isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncing(true);
|
||||
|
||||
try {
|
||||
await syncEnvelope();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
setIsSyncing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Resync the whole envelope if the envelope is mid saving.
|
||||
if (isOpen && (isAutosaving || autosaveError)) {
|
||||
void handleSync();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (envelope.status !== DocumentStatus.DRAFT || envelope.type !== EnvelopeType.DOCUMENT) {
|
||||
return null;
|
||||
}
|
||||
@@ -208,7 +256,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!invalidEnvelopeCode ? (
|
||||
{!invalidEnvelopeCode || isSyncing ? (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
@@ -236,7 +284,16 @@ export const EnvelopeDistributeDialog = ({
|
||||
})}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
{isSyncing ? (
|
||||
<motion.div
|
||||
key={'Flushing'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
>
|
||||
<SpinnerBox spinnerProps={{ size: 'sm' }} className="h-72" />
|
||||
</motion.div>
|
||||
) : distributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<motion.div
|
||||
key={'Emails'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
@@ -339,7 +396,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground p-4">
|
||||
<TooltipContent className="p-4 text-muted-foreground">
|
||||
<DocumentSendEmailMessageHelper />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -347,7 +404,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="bg-background mt-2 h-16 resize-none"
|
||||
className="mt-2 h-16 resize-none bg-background"
|
||||
{...field}
|
||||
maxLength={5000}
|
||||
/>
|
||||
@@ -359,9 +416,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</fieldset>
|
||||
</Form>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{distributionMethod === DocumentDistributionMethod.NONE && (
|
||||
) : distributionMethod === DocumentDistributionMethod.NONE ? (
|
||||
<motion.div
|
||||
key={'Links'}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
@@ -369,7 +424,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
className="min-h-60 rounded-lg border"
|
||||
>
|
||||
<div className="text-muted-foreground py-24 text-center text-sm">
|
||||
<div className="py-24 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
<Trans>We won't send anything to notify recipients.</Trans>
|
||||
</p>
|
||||
@@ -382,7 +437,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -393,7 +448,7 @@ export const EnvelopeDistributeDialog = ({
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
<Button loading={isSubmitting} disabled={isSyncing} type="submit">
|
||||
{distributionMethod === DocumentDistributionMethod.EMAIL ? (
|
||||
<Trans>Send</Trans>
|
||||
) : (
|
||||
@@ -419,7 +474,22 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||
{recipientsMissingSignatureFields.map((recipient) => (
|
||||
<li key={recipient.id}>{recipient.email}</li>
|
||||
<li key={recipient.id}>
|
||||
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with('MISSING_REQUIRED_EMAIL', () => (
|
||||
<AlertDescription>
|
||||
<Trans>The following recipients require an email address:</Trans>
|
||||
|
||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||
{recipientsMissingRequiredEmail.map((recipient) => (
|
||||
<li key={recipient.id}>
|
||||
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
|
||||
@@ -367,7 +367,7 @@ const BillingPlanForm = ({
|
||||
<div className="w-full text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-medium">
|
||||
<Trans>Free</Trans>
|
||||
<Trans context="Plan price">Free</Trans>
|
||||
</p>
|
||||
|
||||
<Badge size="small" variant="neutral" className="ml-1.5">
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const ZSignFieldEmailFormSchema = z.object({
|
||||
email: z.string().min(1, { message: msg`Email is required`.id }),
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.min(1, { message: msg`Email is required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
||||
|
||||
@@ -103,8 +103,8 @@ export const TemplateBulkSendDialog = ({
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to upload CSV. Please check the file format and try again.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -65,7 +66,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
@@ -100,12 +101,29 @@ export function TemplateUseDialog({
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||
defaultValues: {
|
||||
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
||||
{
|
||||
envelopeId,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = response?.data ?? [];
|
||||
|
||||
const generateDefaultFormValues = () => {
|
||||
return {
|
||||
distributeDocument: false,
|
||||
useCustomDocument: false,
|
||||
customDocumentData: [],
|
||||
customDocumentData: envelopeItems.map((item) => ({
|
||||
title: item.title,
|
||||
data: undefined,
|
||||
envelopeItemId: item.id,
|
||||
})),
|
||||
recipients: recipients
|
||||
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
||||
.map((recipient) => {
|
||||
@@ -124,7 +142,12 @@ export function TemplateUseDialog({
|
||||
signingOrder: recipient.signingOrder ?? undefined,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||
defaultValues: generateDefaultFormValues(),
|
||||
});
|
||||
|
||||
const { replace, fields: localCustomDocumentData } = useFieldArray({
|
||||
@@ -132,19 +155,6 @@ export function TemplateUseDialog({
|
||||
name: 'customDocumentData',
|
||||
});
|
||||
|
||||
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
||||
{
|
||||
envelopeId,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = response?.data ?? [];
|
||||
|
||||
const { mutateAsync: createDocumentFromTemplate } =
|
||||
trpc.template.createDocumentFromTemplate.useMutation();
|
||||
|
||||
@@ -214,8 +224,8 @@ export function TemplateUseDialog({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
if (open) {
|
||||
form.reset(generateDefaultFormValues());
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
@@ -322,7 +332,7 @@ export function TemplateUseDialog({
|
||||
<Input
|
||||
{...field}
|
||||
aria-label="Name"
|
||||
placeholder={recipients[index].name || _(msg`Name`)}
|
||||
placeholder={recipients[index].name || _(msg`Recipient ${index + 1}`)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -349,7 +359,7 @@ export function TemplateUseDialog({
|
||||
|
||||
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
className="ml-2 flex items-center text-sm text-muted-foreground"
|
||||
htmlFor="distributeDocument"
|
||||
>
|
||||
<Trans>Send document</Trans>
|
||||
@@ -358,7 +368,7 @@ export function TemplateUseDialog({
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
The document will be immediately sent to recipients if this
|
||||
@@ -378,7 +388,7 @@ export function TemplateUseDialog({
|
||||
|
||||
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
className="ml-2 flex items-center text-sm text-muted-foreground"
|
||||
htmlFor="distributeDocument"
|
||||
>
|
||||
<Trans>Create as pending</Trans>
|
||||
@@ -386,7 +396,7 @@ export function TemplateUseDialog({
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
Create the document as pending and ready to sign.
|
||||
@@ -432,7 +442,7 @@ export function TemplateUseDialog({
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
className="ml-2 flex items-center text-sm text-muted-foreground"
|
||||
htmlFor="useCustomDocument"
|
||||
>
|
||||
<Trans>Upload custom document</Trans>
|
||||
@@ -440,7 +450,7 @@ export function TemplateUseDialog({
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
Upload a custom document to use instead of the template's default
|
||||
@@ -470,19 +480,19 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<FileTextIcon className="text-primary h-5 w-5" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileTextIcon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-foreground truncate text-sm font-medium">
|
||||
<h4 className="truncate text-sm font-medium text-foreground">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{field.value ? (
|
||||
<div>
|
||||
<Trans>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||
|
||||
// Define the schema for configuration
|
||||
@@ -55,7 +56,7 @@ export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.exten
|
||||
nativeId: z.number().optional(),
|
||||
formId: z.string(),
|
||||
name: z.string(),
|
||||
email: z.union([z.string().length(0), z.string().email('Invalid email address')]),
|
||||
email: ZRecipientEmailSchema,
|
||||
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
|
||||
signingOrder: z.number().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { EnvelopeItem, FieldType } from '@prisma/client';
|
||||
@@ -229,8 +230,8 @@ export const ConfigureFieldsView = ({
|
||||
setFieldClipboard(lastActiveField);
|
||||
|
||||
toast({
|
||||
title: 'Copied field',
|
||||
description: 'Copied field to clipboard',
|
||||
title: _(msg`Copied field`),
|
||||
description: _(msg`Copied field to clipboard`),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -150,8 +150,8 @@ export const MultiSignDocumentSigningView = ({
|
||||
onDocumentError?.();
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to complete the document. Please try again.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to complete the document. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -58,6 +58,7 @@ export type TDocumentPreferencesFormSchema = {
|
||||
includeSigningCertificate: boolean | null;
|
||||
includeAuditLog: boolean | null;
|
||||
signatureTypes: DocumentSignatureType[];
|
||||
aiFeaturesEnabled: boolean | null;
|
||||
};
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
@@ -72,11 +73,13 @@ type SettingsSubset = Pick<
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
| 'aiFeaturesEnabled'
|
||||
>;
|
||||
|
||||
export type DocumentPreferencesFormProps = {
|
||||
settings: SettingsSubset;
|
||||
canInherit: boolean;
|
||||
isAiFeaturesConfigured?: boolean;
|
||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -84,6 +87,7 @@ export const DocumentPreferencesForm = ({
|
||||
settings,
|
||||
onFormSubmit,
|
||||
canInherit,
|
||||
isAiFeaturesConfigured = false,
|
||||
}: DocumentPreferencesFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { user, organisations } = useSession();
|
||||
@@ -105,6 +109,7 @@ export const DocumentPreferencesForm = ({
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
aiFeaturesEnabled: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||
@@ -120,6 +125,7 @@ export const DocumentPreferencesForm = ({
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
@@ -312,7 +318,7 @@ export const DocumentPreferencesForm = ({
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
className="bg-background w-full"
|
||||
className="w-full bg-background"
|
||||
enableSearch={false}
|
||||
emptySelectionPlaceholder={
|
||||
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
||||
@@ -378,7 +384,7 @@ export const DocumentPreferencesForm = ({
|
||||
</FormControl>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Preview</Trans>
|
||||
</div>
|
||||
|
||||
@@ -509,6 +515,59 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAiFeaturesConfigured && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="aiFeaturesEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>AI Features</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Enabled</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>Disabled</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Enable AI-powered features such as automatic recipient detection. When
|
||||
enabled, document content will be sent to AI providers. We only use providers
|
||||
that do not retain data for training and prefer European regions where
|
||||
available.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
|
||||
@@ -201,7 +201,7 @@ export const SignInForm = ({
|
||||
.otherwise(() => handleFallbackErrorMessages(error.code));
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(errorMessage),
|
||||
duration: 10000,
|
||||
variant: 'destructive',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type AnimatedDocumentScannerProps = {
|
||||
className?: string;
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
export const AnimatedDocumentScanner = ({
|
||||
className,
|
||||
interval = 2500,
|
||||
}: AnimatedDocumentScannerProps) => {
|
||||
const [magPosition, setMagPosition] = useState({ x: 0, y: 0, page: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const moveInterval = setInterval(() => {
|
||||
setMagPosition({
|
||||
x: Math.random() * 60 - 30,
|
||||
y: Math.random() * 50 - 25,
|
||||
page: Math.random() > 0.5 ? 1 : 0,
|
||||
});
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(moveInterval);
|
||||
}, [interval]);
|
||||
|
||||
return (
|
||||
<div className={cn('relative mx-auto h-36 w-56', className)}>
|
||||
{/* Magnifying glass */}
|
||||
<div
|
||||
className="pointer-events-none absolute z-50 transition-all duration-1000 ease-in-out"
|
||||
style={{
|
||||
left: magPosition.page === 0 ? '25%' : '75%',
|
||||
top: '50%',
|
||||
transform: `translate(calc(-50% + ${magPosition.x}px), calc(-50% + ${magPosition.y}px))`,
|
||||
}}
|
||||
>
|
||||
<SearchIcon className="h-8 w-8 text-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Book container */}
|
||||
<div className="relative h-full w-full animate-pulse" style={{ perspective: '800px' }}>
|
||||
<div className="relative h-full w-full" style={{ transformStyle: 'preserve-3d' }}>
|
||||
{/* Left page */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full w-[calc(50%)] origin-right overflow-hidden rounded-l-md border border-border bg-card shadow-md"
|
||||
style={{ transform: 'rotateY(15deg) skewY(-1deg)' }}
|
||||
>
|
||||
<div className="absolute inset-3 space-y-2">
|
||||
<div className="h-1.5 w-3/4 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-5/6 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-2/3 rounded-sm bg-muted" />
|
||||
<div className="mt-3 h-6 w-3/4 rounded border border-dashed border-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right page */}
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-[calc(50%)] origin-left overflow-hidden rounded-r-md border border-border bg-card shadow-md"
|
||||
style={{ transform: 'rotateY(-15deg) skewY(1deg)' }}
|
||||
>
|
||||
<div className="absolute inset-3 space-y-2">
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-4/5 rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-full rounded-sm bg-muted" />
|
||||
<div className="h-1.5 w-3/5 rounded-sm bg-muted" />
|
||||
<div className="mt-3 h-6 w-2/3 rounded border border-dashed border-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -56,13 +56,13 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
|
||||
/>
|
||||
|
||||
<div
|
||||
className="text-muted-foreground text-sm"
|
||||
className="text-sm text-muted-foreground"
|
||||
title={
|
||||
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
|
||||
}
|
||||
>
|
||||
<p>{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p>{recipient.email || recipient.name}</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+20
-18
@@ -57,12 +57,13 @@ export type DocumentSigningCompleteDialogProps = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
directTemplatePayload?: {
|
||||
recipientPayload?: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
buttonSize?: 'sm' | 'lg';
|
||||
position?: 'start' | 'end' | 'center';
|
||||
disableNameInput?: boolean;
|
||||
};
|
||||
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
@@ -89,10 +90,11 @@ export const DocumentSigningCompleteDialog = ({
|
||||
recipient,
|
||||
disabled = false,
|
||||
allowDictateNextSigner = false,
|
||||
directTemplatePayload,
|
||||
recipientPayload,
|
||||
defaultNextSigner,
|
||||
buttonSize = 'lg',
|
||||
position,
|
||||
disableNameInput = false,
|
||||
}: DocumentSigningCompleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -113,11 +115,11 @@ export const DocumentSigningCompleteDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
|
||||
const recipientForm = useForm<TDirectRecipientFormSchema>({
|
||||
resolver: zodResolver(ZDirectRecipientFormSchema),
|
||||
defaultValues: {
|
||||
name: directTemplatePayload?.name ?? '',
|
||||
email: directTemplatePayload?.email ?? '',
|
||||
name: recipientPayload?.name ?? '',
|
||||
email: recipientPayload?.email ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -145,16 +147,16 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||
try {
|
||||
let directRecipient: { name: string; email: string } | undefined;
|
||||
let recipientOverridePayload: { name: string; email: string } | undefined;
|
||||
|
||||
if (directTemplatePayload && !directTemplatePayload.email) {
|
||||
const isFormValid = await directRecipientForm.trigger();
|
||||
if (recipientPayload && !recipientPayload.email) {
|
||||
const isFormValid = await recipientForm.trigger();
|
||||
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
directRecipient = directRecipientForm.getValues();
|
||||
recipientOverridePayload = recipientForm.getValues();
|
||||
}
|
||||
|
||||
// Check if 2FA is required
|
||||
@@ -168,7 +170,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
? { name: data.name, email: data.email }
|
||||
: undefined;
|
||||
|
||||
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
|
||||
await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
|
||||
} catch (error) {
|
||||
const err = AppError.parseError(error);
|
||||
|
||||
@@ -222,7 +224,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
<div className="max-w-[50ch] text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
@@ -250,19 +252,19 @@ export const DocumentSigningCompleteDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-4 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">{documentTitle}</p>
|
||||
</div>
|
||||
|
||||
{!showTwoFactorForm && (
|
||||
<>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||
{directTemplatePayload && !directTemplatePayload.email && (
|
||||
<Form {...directRecipientForm}>
|
||||
{recipientPayload && !recipientPayload.email && (
|
||||
<Form {...recipientForm}>
|
||||
<div className="mb-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={directRecipientForm.control}
|
||||
control={recipientForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
@@ -274,7 +276,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
{...field}
|
||||
className="mt-2"
|
||||
placeholder={t`Enter your name`}
|
||||
disabled={isNameLocked}
|
||||
disabled={isNameLocked || disableNameInput}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -284,7 +286,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={directRecipientForm.control}
|
||||
control={recipientForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
|
||||
@@ -108,8 +108,8 @@ export const DocumentSigningForm = ({
|
||||
await completeDocument({ nextSigner });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while completing the document. Please try again.',
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while completing the document. Please try again.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
|
||||
+36
-7
@@ -187,45 +187,74 @@ export const DocumentSigningPageViewV1 = ({
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-y-2 sm:mt-2.5 sm:gap-y-0">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="truncate text-muted-foreground" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to view this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to view this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to view this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.SIGNER, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to sign this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to sign this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.APPROVER, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to approve this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to approve this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.with(RecipientRole.ASSISTANT, () =>
|
||||
includeSenderDetails ? (
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
on behalf of "{document.team?.name}" has invited you to assist this document
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>has invited you to assist this document</Trans>
|
||||
<Trans>
|
||||
<span className="truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
</span>{' '}
|
||||
has invited you to assist this document
|
||||
</Trans>
|
||||
),
|
||||
)
|
||||
.otherwise(() => null)}
|
||||
|
||||
+4
-4
@@ -74,8 +74,8 @@ export function DocumentSigningRejectDialog({
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Document rejected',
|
||||
description: 'The document has been successfully rejected.',
|
||||
title: t`Document rejected`,
|
||||
description: t`The document has been successfully rejected.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -88,8 +88,8 @@ export function DocumentSigningRejectDialog({
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while rejecting the document. Please try again.',
|
||||
title: t`Error`,
|
||||
description: t`An error occurred while rejecting the document. Please try again.`,
|
||||
variant: 'destructive',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import { lazy, useEffect, useMemo } from 'react';
|
||||
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon } from 'lucide-react';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import type {
|
||||
TCheckboxFieldMeta,
|
||||
TDateFieldMeta,
|
||||
TDropdownFieldMeta,
|
||||
TEmailFieldMeta,
|
||||
TFieldMetaSchema,
|
||||
TInitialsFieldMeta,
|
||||
TNameFieldMeta,
|
||||
TNumberFieldMeta,
|
||||
TRadioFieldMeta,
|
||||
TSignatureFieldMeta,
|
||||
TTextFieldMeta,
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
import {
|
||||
FIELD_META_DEFAULT_VALUES,
|
||||
type TCheckboxFieldMeta,
|
||||
type TDateFieldMeta,
|
||||
type TDropdownFieldMeta,
|
||||
type TEmailFieldMeta,
|
||||
type TFieldMetaSchema,
|
||||
type TInitialsFieldMeta,
|
||||
type TNameFieldMeta,
|
||||
type TNumberFieldMeta,
|
||||
type TRadioFieldMeta,
|
||||
type TSignatureFieldMeta,
|
||||
type TTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
@@ -31,6 +34,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
|
||||
@@ -41,6 +45,7 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
|
||||
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
||||
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
||||
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||
@@ -67,11 +72,15 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
||||
export const EnvelopeEditorFieldsPage = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
@@ -96,6 +105,24 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onFieldDetectionComplete = (fields: NormalizedFieldWithContext[]) => {
|
||||
for (const field of fields) {
|
||||
editorFields.addField({
|
||||
height: field.height,
|
||||
width: field.width,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
type: field.type,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
recipientId: field.recipientId,
|
||||
page: field.pageNumber,
|
||||
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[field.type]),
|
||||
});
|
||||
}
|
||||
|
||||
setIsAiFieldDialogOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the selected recipient to the first recipient in the envelope.
|
||||
*/
|
||||
@@ -202,6 +229,35 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||
/>
|
||||
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => setIsAiFieldDialogOpen(true)}
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
title={
|
||||
envelope.status !== DocumentStatus.DRAFT
|
||||
? _(msg`You can only detect fields in draft envelopes`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Field details section. */}
|
||||
@@ -243,7 +299,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(FieldSettingsTypeTranslations[selectedField.type])}
|
||||
{_(FieldSettingsTypeTranslations[selectedField.type])}
|
||||
</h3>
|
||||
|
||||
{match(selectedField.type)
|
||||
|
||||
@@ -30,18 +30,11 @@ import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
|
||||
export default function EnvelopeEditorHeader() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const {
|
||||
envelope,
|
||||
isDocument,
|
||||
isTemplate,
|
||||
updateEnvelope,
|
||||
autosaveError,
|
||||
relativePath,
|
||||
editorFields,
|
||||
} = useCurrentEnvelopeEditor();
|
||||
const { envelope, isDocument, isTemplate, updateEnvelope, autosaveError, relativePath } =
|
||||
useCurrentEnvelopeEditor();
|
||||
|
||||
return (
|
||||
<nav className="bg-background border-border w-full border-b px-4 py-3 md:px-6">
|
||||
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/">
|
||||
@@ -147,10 +140,6 @@ export default function EnvelopeEditorHeader() {
|
||||
{isDocument && (
|
||||
<>
|
||||
<EnvelopeDistributeDialog
|
||||
envelope={{
|
||||
...envelope,
|
||||
fields: editorFields.localFields,
|
||||
}}
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
|
||||
+145
-37
@@ -8,12 +8,12 @@ import {
|
||||
type SensorAPI,
|
||||
} from '@hello-pangea/dnd';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -22,10 +22,12 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
import {
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -60,15 +62,15 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZEnvelopeRecipientsForm = z.object({
|
||||
signers: z.array(
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
id: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.min(1),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
@@ -85,14 +87,36 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
const { user } = useSession();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||
|
||||
// AI recipient detection dialog state
|
||||
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
|
||||
|
||||
const onAiDialogOpenChange = (open: boolean) => {
|
||||
setIsAiDialogOpen(open);
|
||||
|
||||
if (!open && searchParams.get('ai') === 'true') {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
|
||||
newParams.delete('ai');
|
||||
|
||||
return newParams;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const initialId = useId();
|
||||
@@ -201,12 +225,13 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
keyName: 'nativeId',
|
||||
});
|
||||
|
||||
const emptySigners = useCallback(
|
||||
() => form.getValues('signers').filter((signer) => signer.email === ''),
|
||||
[form],
|
||||
const emptySignerIndex = watchedSigners.findIndex(
|
||||
(signer) =>
|
||||
!signer.name &&
|
||||
!signer.email &&
|
||||
envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
|
||||
);
|
||||
|
||||
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
||||
const isUserAlreadyARecipient = watchedSigners.some(
|
||||
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
|
||||
);
|
||||
@@ -244,6 +269,71 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
|
||||
const currentSigners = form.getValues('signers');
|
||||
|
||||
let nextSigningOrder =
|
||||
currentSigners.length > 0
|
||||
? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1
|
||||
: 1;
|
||||
|
||||
// If the only signer is the default empty signer lets just replace it with the detected recipients
|
||||
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
|
||||
form.setValue(
|
||||
'signers',
|
||||
detectedRecipients.map((recipient, index) => ({
|
||||
formId: nanoid(12),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
actionAuth: [],
|
||||
signingOrder: index + 1,
|
||||
})),
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const recipient of detectedRecipients) {
|
||||
const emailExists = currentSigners.some(
|
||||
(s) => s.email.toLowerCase() === recipient.email.toLowerCase(),
|
||||
);
|
||||
|
||||
const nameExists = currentSigners.some(
|
||||
(s) => s.name.toLowerCase() === recipient.name.toLowerCase(),
|
||||
);
|
||||
|
||||
if ((emailExists && recipient.email) || (nameExists && recipient.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
currentSigners.push({
|
||||
formId: nanoid(12),
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
actionAuth: [],
|
||||
signingOrder: nextSigningOrder,
|
||||
});
|
||||
|
||||
nextSigningOrder += 1;
|
||||
}
|
||||
|
||||
form.setValue('signers', normalizeSigningOrders(currentSigners), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Recipients added`,
|
||||
description: t`${detectedRecipients.length} recipient(s) have been added from AI detection.`,
|
||||
});
|
||||
};
|
||||
|
||||
const onRemoveSigner = (index: number) => {
|
||||
const signer = signers[index];
|
||||
|
||||
@@ -306,8 +396,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
index: number,
|
||||
suggestion: RecipientAutoCompleteOption,
|
||||
) => {
|
||||
setValue(`signers.${index}.email`, suggestion.email);
|
||||
setValue(`signers.${index}.name`, suggestion.name || '');
|
||||
setValue(`signers.${index}.email`, suggestion.email, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
setValue(`signers.${index}.name`, suggestion.name || '', {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
@@ -460,21 +556,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const formValueSigners = formValues.signers || [];
|
||||
|
||||
// Remove the last signer if it's empty.
|
||||
const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
|
||||
if (i === formValueSigners.length - 1 && signer.email === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
|
||||
...formValues,
|
||||
signers: nonEmptyRecipients,
|
||||
});
|
||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
|
||||
|
||||
if (!validatedFormValues.success) {
|
||||
return;
|
||||
@@ -543,6 +625,26 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => setIsAiDialogOpen(true)}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
<Trans>Detect recipients with AI</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-row items-center"
|
||||
@@ -570,7 +672,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
<CardContent>
|
||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||
<Form {...form}>
|
||||
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
|
||||
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
|
||||
{organisation.organisationClaim.flags.cfr21 && (
|
||||
<div className="flex flex-row items-center">
|
||||
<Checkbox
|
||||
@@ -618,9 +720,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
|
||||
}
|
||||
disabled={isSubmitting || hasDocumentBeenSent}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -634,7 +734,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<span className="ml-1 cursor-help text-muted-foreground">
|
||||
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -679,7 +779,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground ml-1 cursor-help">
|
||||
<span className="ml-1 cursor-help text-muted-foreground">
|
||||
<HelpCircleIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -716,7 +816,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
>
|
||||
{signers.map((signer, index) => (
|
||||
<Draggable
|
||||
key={`${signer.id}-${signer.signingOrder}`}
|
||||
key={`${signer.nativeId}-${signer.signingOrder}`}
|
||||
draggableId={signer['nativeId']}
|
||||
index={index}
|
||||
isDragDisabled={
|
||||
@@ -732,7 +832,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={cn('py-1', {
|
||||
'bg-widget-foreground pointer-events-none rounded-md pt-2':
|
||||
'pointer-events-none rounded-md bg-widget-foreground pt-2':
|
||||
snapshot.isDragging,
|
||||
})}
|
||||
>
|
||||
@@ -806,7 +906,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
})}
|
||||
>
|
||||
{!showAdvancedSettings && index === 0 && (
|
||||
<FormLabel required>
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
)}
|
||||
@@ -860,7 +960,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
<FormControl>
|
||||
<RecipientAutoCompleteInput
|
||||
type="text"
|
||||
placeholder={t`Name`}
|
||||
placeholder={t`Recipient ${index + 1}`}
|
||||
{...field}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
@@ -992,6 +1092,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
onOpenChange={setShowSigningOrderConfirmation}
|
||||
onConfirm={handleSigningOrderDisable}
|
||||
/>
|
||||
|
||||
<AiRecipientDetectionDialog
|
||||
open={isAiDialogOpen}
|
||||
onOpenChange={onAiDialogOpenChange}
|
||||
onComplete={onAiDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
|
||||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
|
||||
import type { DropResult } from '@hello-pangea/dnd';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { msg, plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
|
||||
@@ -226,7 +226,12 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
}
|
||||
|
||||
if (maximumEnvelopeItemCount <= localFiles.length) {
|
||||
return msg`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`;
|
||||
return msg({
|
||||
message: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -240,7 +245,10 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
|
||||
if (maxItemsReached) {
|
||||
toast({
|
||||
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||
title: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -152,30 +152,30 @@ export default function EnvelopeEditor() {
|
||||
envelopeEditorSteps.find((step) => step.id === currentStep) || envelopeEditorSteps[0];
|
||||
|
||||
return (
|
||||
<div className="dark:bg-background h-screen w-screen bg-gray-50">
|
||||
<div className="h-screen w-screen bg-gray-50 dark:bg-background">
|
||||
<EnvelopeEditorHeader />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||
{/* Left Section - Step Navigation */}
|
||||
<div className="bg-background border-border flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4">
|
||||
<div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-r border-border bg-background py-4">
|
||||
{/* Left section step selector. */}
|
||||
<div className="px-4">
|
||||
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||
<h3 className="flex items-end justify-between text-sm font-semibold text-foreground">
|
||||
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
||||
|
||||
<span className="text-muted-foreground bg-muted/50 ml-2 rounded border px-2 py-0.5 text-xs">
|
||||
<span className="ml-2 rounded border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Trans context="The step counter">
|
||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||
</Trans>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="bg-muted relative my-4 h-[4px] rounded-md">
|
||||
<div className="relative my-4 h-[4px] rounded-md bg-muted">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
className="absolute inset-y-0 left-0 bg-documenso"
|
||||
style={{
|
||||
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
|
||||
}}
|
||||
@@ -219,7 +219,7 @@ export default function EnvelopeEditor() {
|
||||
>
|
||||
{t(step.title)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{t(step.description)}</div>
|
||||
<div className="text-xs text-muted-foreground">{t(step.description)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,7 +232,7 @@ export default function EnvelopeEditor() {
|
||||
|
||||
{/* Quick Actions. */}
|
||||
<div className="space-y-3 px-4">
|
||||
<h4 className="text-foreground text-sm font-semibold">
|
||||
<h4 className="text-sm font-semibold text-foreground">
|
||||
<Trans>Quick Actions</Trans>
|
||||
</h4>
|
||||
<EnvelopeEditorSettingsDialog
|
||||
@@ -246,10 +246,6 @@ export default function EnvelopeEditor() {
|
||||
|
||||
{isDocument && (
|
||||
<EnvelopeDistributeDialog
|
||||
envelope={{
|
||||
...envelope,
|
||||
fields: editorFields.localFields,
|
||||
}}
|
||||
documentRootPath={relativePath.documentRootPath}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
@@ -39,8 +41,15 @@ export const EnvelopeRecipientSelector = ({
|
||||
fields,
|
||||
align = 'start',
|
||||
}: EnvelopeRecipientSelectorProps) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const getRecipientLabel = useCallback(
|
||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
[recipients],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -49,7 +58,7 @@ export const EnvelopeRecipientSelector = ({
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
|
||||
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
|
||||
getRecipientColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === selectedRecipient?.id),
|
||||
@@ -59,16 +68,12 @@ export const EnvelopeRecipientSelector = ({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{selectedRecipient?.email && (
|
||||
{selectedRecipient && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedRecipient?.name} ({selectedRecipient?.email})
|
||||
{getRecipientLabel(selectedRecipient)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!selectedRecipient?.email && (
|
||||
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
|
||||
)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -105,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
fields,
|
||||
placeholder,
|
||||
}: EnvelopeRecipientSelectorCommandProps) => {
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
const recipientsByRole = useCallback(() => {
|
||||
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||
@@ -154,6 +159,11 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
[fields, recipients],
|
||||
);
|
||||
|
||||
const getRecipientLabel = useCallback(
|
||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
[recipients],
|
||||
);
|
||||
|
||||
return (
|
||||
<Command
|
||||
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
|
||||
@@ -162,21 +172,21 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
<CommandInput placeholder={placeholder} />
|
||||
|
||||
<CommandEmpty>
|
||||
<span className="text-muted-foreground inline-block px-4">
|
||||
<span className="inline-block px-4 text-muted-foreground">
|
||||
<Trans>No recipient matching this description was found.</Trans>
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
|
||||
<CommandGroup key={roleIndex}>
|
||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
|
||||
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
|
||||
</div>
|
||||
|
||||
{roleRecipients.length === 0 && (
|
||||
<div
|
||||
key={`${role}-empty`}
|
||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
|
||||
>
|
||||
<Trans>No recipients with this role</Trans>
|
||||
</div>
|
||||
@@ -205,18 +215,12 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn('text-foreground/70 truncate', {
|
||||
className={cn('truncate text-foreground/70', {
|
||||
'text-foreground/80': recipient.id === selectedRecipient?.id,
|
||||
'opacity-50': isRecipientDisabled(recipient.id),
|
||||
})}
|
||||
>
|
||||
{recipient.name && (
|
||||
<span title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
|
||||
{getRecipientLabel(recipient)}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center justify-center">
|
||||
@@ -234,7 +238,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
<Info className="z-50 ml-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
This document has already been sent to this recipient. You can no longer
|
||||
edit this recipient.
|
||||
@@ -250,3 +254,22 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
|
||||
if (recipient.name && recipient.email) {
|
||||
return `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
if (recipient.name) {
|
||||
return recipient.name;
|
||||
}
|
||||
|
||||
if (recipient.email) {
|
||||
return recipient.email;
|
||||
}
|
||||
|
||||
// Since objects are basically pointers we can use `indexOf` rather than `findIndex`
|
||||
const index = recipients.indexOf(recipient);
|
||||
|
||||
return i18n._(msg`Recipient ${index + 1}`);
|
||||
};
|
||||
|
||||
+16
-4
@@ -80,12 +80,14 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
const handleOnCompleteClick = async (
|
||||
nextSigner?: { name: string; email: string },
|
||||
accessAuthOptions?: TRecipientAccessAuth,
|
||||
recipientDetails?: { name: string; email: string },
|
||||
) => {
|
||||
try {
|
||||
await completeDocument({
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
accessAuthOptions,
|
||||
recipientOverride: recipientDetails,
|
||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||
});
|
||||
|
||||
@@ -205,21 +207,30 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const directTemplatePayload = useMemo(() => {
|
||||
const recipientPayload = useMemo(() => {
|
||||
if (!isDirectTemplate) {
|
||||
return;
|
||||
return {
|
||||
name:
|
||||
recipient.name ||
|
||||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
'',
|
||||
email:
|
||||
recipient.email ||
|
||||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
|
||||
'',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: fullName,
|
||||
email: email,
|
||||
};
|
||||
}, [email, fullName, isDirectTemplate]);
|
||||
}, [email, fullName, isDirectTemplate, recipient.email, recipient.name, recipient.fields]);
|
||||
|
||||
return (
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isPending}
|
||||
directTemplatePayload={directTemplatePayload}
|
||||
recipientPayload={recipientPayload}
|
||||
onSignatureComplete={
|
||||
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
|
||||
}
|
||||
@@ -230,6 +241,7 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
allowDictateNextSigner={Boolean(
|
||||
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
|
||||
)}
|
||||
disableNameInput={!isDirectTemplate && recipient.name !== ''}
|
||||
defaultNextSigner={
|
||||
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
@@ -115,7 +116,9 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@@ -153,7 +156,10 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
|
||||
if (maxItemsReached) {
|
||||
toast({
|
||||
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||
title: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -220,9 +226,9 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
{children}
|
||||
|
||||
{isDragActive && (
|
||||
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||
<div className="fixed left-0 top-0 z-[9999] h-full w-full bg-muted/60 backdrop-blur-[4px]">
|
||||
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||
<h2 className="text-foreground text-2xl font-semibold">
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
{type === EnvelopeType.DOCUMENT ? (
|
||||
<Trans>Upload Document</Trans>
|
||||
) : (
|
||||
@@ -230,7 +236,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-md mt-4">
|
||||
<p className="text-md mt-4 text-muted-foreground">
|
||||
<Trans>Drag and drop your PDF file here</Trans>
|
||||
</p>
|
||||
|
||||
@@ -247,7 +253,7 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/80 mt-4 text-sm">
|
||||
<p className="mt-4 text-sm text-muted-foreground/80">
|
||||
<Trans>
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</Trans>
|
||||
@@ -258,10 +264,10 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
|
||||
<div className="absolute inset-0 z-50 bg-muted/30 backdrop-blur-[2px]">
|
||||
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||
<p className="text-foreground mt-8 font-medium">
|
||||
<Loader className="h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-8 font-medium text-foreground">
|
||||
<Trans>Uploading</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { msg, plural } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
@@ -108,7 +108,9 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
? formatDocumentsPath(team.url)
|
||||
: formatTemplatesPath(team.url);
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit`);
|
||||
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
|
||||
|
||||
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
|
||||
|
||||
toast({
|
||||
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
|
||||
@@ -153,7 +155,10 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
|
||||
if (maxItemsReached) {
|
||||
toast({
|
||||
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
|
||||
title: plural(maximumEnvelopeItemCount, {
|
||||
one: `You cannot upload more than # item per envelope.`,
|
||||
other: `You cannot upload more than # items per envelope.`,
|
||||
}),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
@@ -83,8 +83,8 @@ export const StackAvatarsWithTooltip = ({
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -107,8 +107,8 @@ export const StackAvatarsWithTooltip = ({
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@ export const AdminDashboardUsersTable = ({
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: 'ID',
|
||||
header: _(msg`ID`),
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ export const AdminDocumentRecipientItemTable = ({ recipient }: RecipientItemProp
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: 'ID',
|
||||
header: _(msg`ID`),
|
||||
accessorKey: 'id',
|
||||
cell: ({ row }) => <div>{row.original.id}</div>,
|
||||
},
|
||||
|
||||
@@ -113,7 +113,11 @@ export const AdminOrganisationsTable = ({
|
||||
isPaid ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{isPaid ? t`Paid` : t`Free`}
|
||||
{isPaid ? (
|
||||
<Trans context="Subscription status">Paid</Trans>
|
||||
) : (
|
||||
<Trans context="Subscription status">Free</Trans>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -131,7 +135,7 @@ export const AdminOrganisationsTable = ({
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
) : (
|
||||
'None'
|
||||
<Trans>None</Trans>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -96,11 +96,11 @@ export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
|
||||
cell: ({ row }) => <span>{formatDocumentAuditLogAction(_, row.original).description}</span>,
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
header: _(msg`IP Address`),
|
||||
accessorKey: 'ipAddress',
|
||||
},
|
||||
{
|
||||
header: 'Browser',
|
||||
header: _(msg`Browser`),
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.userAgent) {
|
||||
return 'N/A';
|
||||
|
||||
@@ -104,7 +104,7 @@ export const SettingsSecurityActivityTable = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
header: _(msg`IP Address`),
|
||||
accessorKey: 'ipAddress',
|
||||
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
||||
},
|
||||
|
||||
@@ -28,19 +28,19 @@ export const UserBillingOrganisationsTable = () => {
|
||||
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
|
||||
return match(status)
|
||||
.with(SubscriptionStatus.ACTIVE, () => ({
|
||||
label: t`Active`,
|
||||
label: t({ message: `Active`, context: `Subscription status` }),
|
||||
variant: 'default' as const,
|
||||
}))
|
||||
.with(SubscriptionStatus.PAST_DUE, () => ({
|
||||
label: t`Past Due`,
|
||||
label: t({ message: `Past Due`, context: `Subscription status` }),
|
||||
variant: 'warning' as const,
|
||||
}))
|
||||
.with(SubscriptionStatus.INACTIVE, () => ({
|
||||
label: t`Inactive`,
|
||||
label: t({ message: `Inactive`, context: `Subscription status` }),
|
||||
variant: 'neutral' as const,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
label: t`Free`,
|
||||
label: t({ message: `Free`, context: `Subscription status` }),
|
||||
variant: 'neutral' as const,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import { HydratedRouter } from 'react-router/dom';
|
||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
|
||||
import './utils/polyfills/promise-with-resolvers';
|
||||
|
||||
function PosthogInit() {
|
||||
const postHogConfig = extractPostHogConfig();
|
||||
|
||||
|
||||
+16
-2
@@ -46,12 +46,16 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
|
||||
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)) {
|
||||
lang = extractLocaleData({ headers: request.headers }).lang;
|
||||
}
|
||||
|
||||
const disableAnimations = cookieHeader.includes('__disable_animations=true');
|
||||
|
||||
let organisations = null;
|
||||
|
||||
if (session.isAuthenticated) {
|
||||
@@ -62,6 +66,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
{
|
||||
lang,
|
||||
theme: getTheme(),
|
||||
disableAnimations,
|
||||
session: session.isAuthenticated
|
||||
? {
|
||||
user: session.user,
|
||||
@@ -92,7 +97,8 @@ export function Layout({ 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();
|
||||
|
||||
@@ -111,6 +117,14 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<meta name="google" content="notranslate" />
|
||||
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
|
||||
|
||||
{disableAnimations && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
|
||||
<script>0</script>
|
||||
</head>
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function AdminDocumentsPage() {
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Last updated',
|
||||
header: _(msg`Last updated`),
|
||||
accessorKey: 'updatedAt',
|
||||
cell: ({ row }) => i18n.date(row.original.updatedAt),
|
||||
},
|
||||
|
||||
@@ -152,12 +152,6 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
|
||||
<div className="mt-5 grid grid-cols-2 gap-8">
|
||||
<MonthlyActiveUsersChart title={_(msg`MAU (signed in)`)} data={monthlyActiveUsers} />
|
||||
|
||||
<MonthlyActiveUsersChart
|
||||
title={_(msg`Cumulative MAU (signed in)`)}
|
||||
data={monthlyActiveUsers}
|
||||
cummulative
|
||||
/>
|
||||
|
||||
<AdminStatsUsersWithDocumentsChart
|
||||
data={monthlyUsersWithDocuments}
|
||||
title={_(msg`MAU (created document)`)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useLoaderData } from 'react-router';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -19,9 +21,16 @@ export function meta() {
|
||||
return appMetaTags('Document Preferences');
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsDocumentPage() {
|
||||
const { organisations } = useSession();
|
||||
export const loader = () => {
|
||||
return {
|
||||
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||
};
|
||||
};
|
||||
|
||||
export default function OrganisationSettingsDocumentPage() {
|
||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||
|
||||
const { organisations } = useSession();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
@@ -48,6 +57,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
aiFeaturesEnabled,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
@@ -56,7 +66,8 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
documentDateFormat === null ||
|
||||
includeSenderDetails === null ||
|
||||
includeSigningCertificate === null ||
|
||||
includeAuditLog === null
|
||||
includeAuditLog === null ||
|
||||
aiFeaturesEnabled === null
|
||||
) {
|
||||
throw new Error('Should not be possible.');
|
||||
}
|
||||
@@ -74,6 +85,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
aiFeaturesEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,7 +105,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,6 +122,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
<section>
|
||||
<DocumentPreferencesForm
|
||||
canInherit={false}
|
||||
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onDocumentPreferencesFormSubmit}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useLoaderData } from 'react-router';
|
||||
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -17,7 +19,15 @@ export function meta() {
|
||||
return appMetaTags('Document Preferences');
|
||||
}
|
||||
|
||||
export const loader = () => {
|
||||
return {
|
||||
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
|
||||
};
|
||||
};
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
@@ -40,6 +50,7 @@ export default function TeamsSettingsPage() {
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
aiFeaturesEnabled,
|
||||
} = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
@@ -52,6 +63,7 @@ export default function TeamsSettingsPage() {
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
aiFeaturesEnabled,
|
||||
...(signatureTypes.length === 0
|
||||
? {
|
||||
typedSignatureEnabled: null,
|
||||
@@ -82,7 +94,7 @@ export default function TeamsSettingsPage() {
|
||||
if (isLoadingTeam || !teamWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +109,7 @@ export default function TeamsSettingsPage() {
|
||||
<section>
|
||||
<DocumentPreferencesForm
|
||||
canInherit={true}
|
||||
isAiFeaturesConfigured={isAiFeaturesConfigured}
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onDocumentPreferencesSubmit}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
|
||||
import { data, isRouteErrorResponse } from 'react-router';
|
||||
@@ -125,6 +126,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
|
||||
const { token, type, user, organisation } = loaderData;
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -136,12 +138,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
|
||||
await navigate('/');
|
||||
|
||||
toast({
|
||||
title: 'Account link declined',
|
||||
title: _(msg`Account link declined`),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error declining account link',
|
||||
title: _(msg`Error declining account link`),
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
@@ -153,12 +155,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
|
||||
await navigate(formatOrganisationLoginPath(organisation.url));
|
||||
|
||||
toast({
|
||||
title: 'Account linked successfully',
|
||||
title: _(msg`Account linked successfully`),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error linking account',
|
||||
title: _(msg`Error linking account`),
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -41,7 +41,9 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
const token = url.searchParams.get('token') || '';
|
||||
|
||||
// We also know that the token is valid, but we need the userId + teamId
|
||||
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
|
||||
const result = await verifyEmbeddingPresignToken({ token, scope: `documentId:${id}` }).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Invalid token');
|
||||
|
||||
@@ -41,7 +41,9 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
const token = url.searchParams.get('token') || '';
|
||||
|
||||
// We also know that the token is valid, but we need the userId + teamId
|
||||
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
|
||||
const result = await verifyEmbeddingPresignToken({ token, scope: `templateId:${id}` }).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Invalid token');
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Polyfill for Promise.withResolvers (ES2024)
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers
|
||||
*/
|
||||
|
||||
type PromiseWithResolvers<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
// We're patching here
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const GlobalPromise = globalThis.Promise as any;
|
||||
|
||||
if (typeof GlobalPromise.withResolvers !== 'function') {
|
||||
GlobalPromise.withResolvers = function <T>(): PromiseWithResolvers<T> {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -108,5 +108,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.1.0"
|
||||
"version": "2.2.5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type TDetectFieldsRequest,
|
||||
ZNormalizedFieldWithContextSchema,
|
||||
} from './detect-fields.types';
|
||||
|
||||
export type { TDetectFieldsRequest };
|
||||
|
||||
// Stream event schemas
|
||||
const ZProgressEventSchema = z.object({
|
||||
type: z.literal('progress'),
|
||||
pagesProcessed: z.number(),
|
||||
totalPages: z.number(),
|
||||
fieldsDetected: z.number(),
|
||||
});
|
||||
|
||||
const ZKeepaliveEventSchema = z.object({
|
||||
type: z.literal('keepalive'),
|
||||
});
|
||||
|
||||
const ZErrorEventSchema = z.object({
|
||||
type: z.literal('error'),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const ZCompleteEventSchema = z.object({
|
||||
type: z.literal('complete'),
|
||||
fields: z.array(ZNormalizedFieldWithContextSchema),
|
||||
});
|
||||
|
||||
const ZStreamEventSchema = z.discriminatedUnion('type', [
|
||||
ZProgressEventSchema,
|
||||
ZKeepaliveEventSchema,
|
||||
ZErrorEventSchema,
|
||||
ZCompleteEventSchema,
|
||||
]);
|
||||
|
||||
export type DetectFieldsProgressEvent = z.infer<typeof ZProgressEventSchema>;
|
||||
export type DetectFieldsCompleteEvent = z.infer<typeof ZCompleteEventSchema>;
|
||||
export type DetectFieldsStreamEvent = z.infer<typeof ZStreamEventSchema>;
|
||||
|
||||
const ZApiErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export class AiApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AiApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export type DetectFieldsOptions = {
|
||||
request: TDetectFieldsRequest;
|
||||
onProgress?: (event: DetectFieldsProgressEvent) => void;
|
||||
onComplete: (event: DetectFieldsCompleteEvent) => void;
|
||||
onError: (error: AiApiError) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect fields from an envelope using AI with streaming support.
|
||||
*/
|
||||
export const detectFields = async ({
|
||||
request,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
signal,
|
||||
}: DetectFieldsOptions): Promise<void> => {
|
||||
const response = await fetch('/api/ai/detect-fields', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
// Handle non-streaming error responses (auth failures, etc.)
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const parsed = ZApiErrorResponseSchema.parse(JSON.parse(text));
|
||||
|
||||
throw new AiApiError(parsed.error, response.status);
|
||||
} catch (e) {
|
||||
if (e instanceof AiApiError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw new AiApiError('Failed to detect fields', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new AiApiError('No response body', 500);
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
let done = false;
|
||||
|
||||
while (!done) {
|
||||
const result = await reader.read();
|
||||
done = result.done;
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const value = result.value;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete lines
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = ZStreamEventSchema.parse(JSON.parse(line));
|
||||
|
||||
switch (event.type) {
|
||||
case 'progress':
|
||||
onProgress?.(event);
|
||||
break;
|
||||
case 'keepalive':
|
||||
// Ignore keepalive, it's just to prevent timeout
|
||||
break;
|
||||
case 'error':
|
||||
onError(new AiApiError(event.message, 500));
|
||||
return;
|
||||
case 'complete':
|
||||
onComplete(event);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed lines
|
||||
console.warn('Failed to parse stream event:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { streamText } from 'hono/streaming';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { detectFieldsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-fields';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { ZDetectFieldsRequestSchema } from './detect-fields.types';
|
||||
|
||||
const KEEPALIVE_INTERVAL_MS = 5000;
|
||||
|
||||
export const detectFieldsRoute = new Hono<HonoEnv>().post(
|
||||
'/',
|
||||
sValidator('json', ZDetectFieldsRequestSchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
try {
|
||||
const { envelopeId, teamId, context } = c.req.valid('json');
|
||||
|
||||
const session = await getSession(c);
|
||||
|
||||
if (!session.user) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You must be logged in to detect fields',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user has access to the team (abort early)
|
||||
const team = await getTeamById({
|
||||
userId: session.user.id,
|
||||
teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have access to this team',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if AI features are enabled for the team
|
||||
const { aiFeaturesEnabled } = team.derivedSettings;
|
||||
|
||||
if (!aiFeaturesEnabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'AI features are not enabled for this team',
|
||||
});
|
||||
}
|
||||
|
||||
if (!IS_AI_FEATURES_CONFIGURED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'AI features are not configured. Please contact support to enable AI features.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-fields.start',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
hasContext: !!context,
|
||||
});
|
||||
|
||||
// Return streaming response with NDJSON
|
||||
return streamText(c, async (stream) => {
|
||||
// Start keepalive to prevent connection timeout
|
||||
let interval: NodeJS.Timeout | null = setInterval(() => {
|
||||
void stream.writeln(JSON.stringify({ type: 'keepalive' }));
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
const allFields = await detectFieldsFromEnvelope({
|
||||
context,
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
onProgress: (progress) => {
|
||||
void stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
pagesProcessed: progress.pagesProcessed,
|
||||
totalPages: progress.totalPages,
|
||||
fieldsDetected: progress.fieldsDetected,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Clear keepalive before sending final response
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-fields.complete',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
fieldCount: allFields.length,
|
||||
});
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'complete',
|
||||
fields: allFields,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
// Clear keepalive on error
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
// The logger below it stringifies the error, using `console.error`
|
||||
// to attempt to get a stack trace
|
||||
console.error(error);
|
||||
|
||||
logger.error({
|
||||
event: 'ai.detect-fields.error',
|
||||
error,
|
||||
});
|
||||
|
||||
const message = error instanceof AppError ? error.message : 'Failed to detect fields';
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle errors that occur before streaming starts
|
||||
logger.error({
|
||||
event: 'ai.detect-fields.error',
|
||||
error,
|
||||
});
|
||||
|
||||
if (error instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json(body, status);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Failed to detect fields' }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
ZConfidenceLevel,
|
||||
ZDetectableFieldType,
|
||||
} from '@documenso/lib/server-only/ai/envelope/detect-fields/schema';
|
||||
|
||||
export const ZDetectFieldsRequestSchema = z.object({
|
||||
envelopeId: z.string().min(1).describe('The ID of the envelope to detect fields from.'),
|
||||
teamId: z.number().describe('The ID of the team the envelope belongs to.'),
|
||||
context: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional context about recipients to help map fields (e.g., "David is the Employee, Lucas is the Manager").',
|
||||
),
|
||||
});
|
||||
|
||||
export type TDetectFieldsRequest = z.infer<typeof ZDetectFieldsRequestSchema>;
|
||||
|
||||
// Schema for fields returned from streaming API (before recipient resolution)
|
||||
export const ZNormalizedFieldWithPageSchema = z.object({
|
||||
type: ZDetectableFieldType,
|
||||
recipientKey: z.string(),
|
||||
positionX: z.number(),
|
||||
positionY: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
confidence: ZConfidenceLevel,
|
||||
pageNumber: z.number(),
|
||||
});
|
||||
|
||||
export type TNormalizedFieldWithPage = z.infer<typeof ZNormalizedFieldWithPageSchema>;
|
||||
|
||||
// Schema for fields after recipient resolution
|
||||
export const ZNormalizedFieldWithContextSchema = z.object({
|
||||
type: ZDetectableFieldType,
|
||||
positionX: z.number(),
|
||||
positionY: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
confidence: ZConfidenceLevel,
|
||||
pageNumber: z.number(),
|
||||
recipientId: z.number(),
|
||||
envelopeItemId: z.string(),
|
||||
});
|
||||
|
||||
export type TNormalizedFieldWithContext = z.infer<typeof ZNormalizedFieldWithContextSchema>;
|
||||
|
||||
export const ZDetectFieldsResponseSchema = z.object({
|
||||
fields: z.array(ZNormalizedFieldWithContextSchema),
|
||||
});
|
||||
|
||||
export type TDetectFieldsResponse = z.infer<typeof ZDetectFieldsResponseSchema>;
|
||||
@@ -0,0 +1,160 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
|
||||
import { type TDetectRecipientsRequest } from './detect-recipients.types';
|
||||
|
||||
export type { TDetectRecipientsRequest };
|
||||
|
||||
// Stream event schemas
|
||||
const ZProgressEventSchema = z.object({
|
||||
type: z.literal('progress'),
|
||||
pagesProcessed: z.number(),
|
||||
totalPages: z.number(),
|
||||
recipientsDetected: z.number(),
|
||||
});
|
||||
|
||||
const ZKeepaliveEventSchema = z.object({
|
||||
type: z.literal('keepalive'),
|
||||
});
|
||||
|
||||
const ZErrorEventSchema = z.object({
|
||||
type: z.literal('error'),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const ZCompleteEventSchema = z.object({
|
||||
type: z.literal('complete'),
|
||||
recipients: z.array(ZDetectedRecipientSchema),
|
||||
});
|
||||
|
||||
const ZStreamEventSchema = z.discriminatedUnion('type', [
|
||||
ZProgressEventSchema,
|
||||
ZKeepaliveEventSchema,
|
||||
ZErrorEventSchema,
|
||||
ZCompleteEventSchema,
|
||||
]);
|
||||
|
||||
export type DetectRecipientsProgressEvent = z.infer<typeof ZProgressEventSchema>;
|
||||
export type DetectRecipientsCompleteEvent = z.infer<typeof ZCompleteEventSchema>;
|
||||
export type DetectRecipientsStreamEvent = z.infer<typeof ZStreamEventSchema>;
|
||||
|
||||
const ZApiErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export class AiApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AiApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export type DetectRecipientsOptions = {
|
||||
request: TDetectRecipientsRequest;
|
||||
onProgress?: (event: DetectRecipientsProgressEvent) => void;
|
||||
onComplete: (event: DetectRecipientsCompleteEvent) => void;
|
||||
onError: (error: AiApiError) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect recipients from an envelope using AI with streaming support.
|
||||
*/
|
||||
export const detectRecipients = async ({
|
||||
request,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError,
|
||||
signal,
|
||||
}: DetectRecipientsOptions): Promise<void> => {
|
||||
const response = await fetch('/api/ai/detect-recipients', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
});
|
||||
|
||||
// Handle non-streaming error responses (auth failures, etc.)
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const parsed = ZApiErrorResponseSchema.parse(JSON.parse(text));
|
||||
|
||||
throw new AiApiError(parsed.error, response.status);
|
||||
} catch (e) {
|
||||
if (e instanceof AiApiError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw new AiApiError('Failed to detect recipients', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
if (!reader) {
|
||||
throw new AiApiError('No response body', 500);
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
let done = false;
|
||||
|
||||
while (!done) {
|
||||
const result = await reader.read();
|
||||
done = result.done;
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const value = result.value;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete lines
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = ZStreamEventSchema.parse(JSON.parse(line));
|
||||
|
||||
switch (event.type) {
|
||||
case 'progress':
|
||||
onProgress?.(event);
|
||||
break;
|
||||
case 'keepalive':
|
||||
// Ignore keepalive, it's just to prevent timeout
|
||||
break;
|
||||
case 'error':
|
||||
onError(new AiApiError(event.message, 500));
|
||||
return;
|
||||
case 'complete':
|
||||
onComplete(event);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed lines
|
||||
console.warn('Failed to parse stream event:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { streamText } from 'hono/streaming';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { detectRecipientsFromEnvelope } from '@documenso/lib/server-only/ai/envelope/detect-recipients';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { ZDetectRecipientsRequestSchema } from './detect-recipients.types';
|
||||
|
||||
const KEEPALIVE_INTERVAL_MS = 5000;
|
||||
|
||||
export const detectRecipientsRoute = new Hono<HonoEnv>().post(
|
||||
'/',
|
||||
sValidator('json', ZDetectRecipientsRequestSchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
try {
|
||||
const { envelopeId, teamId } = c.req.valid('json');
|
||||
|
||||
const session = await getSession(c);
|
||||
|
||||
if (!session.user) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You must be logged in to detect recipients',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify user has access to the team (abort early)
|
||||
const team = await getTeamById({
|
||||
userId: session.user.id,
|
||||
teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have access to this team',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if AI features are enabled for the team
|
||||
const { aiFeaturesEnabled } = team.derivedSettings;
|
||||
|
||||
if (!aiFeaturesEnabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'AI features are not enabled for this team',
|
||||
});
|
||||
}
|
||||
|
||||
if (!IS_AI_FEATURES_CONFIGURED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'AI features are not configured. Please contact support to enable AI features.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-recipients.start',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
// Return streaming response with NDJSON
|
||||
return streamText(c, async (stream) => {
|
||||
// Start keepalive to prevent connection timeout
|
||||
let interval: NodeJS.Timeout | null = setInterval(() => {
|
||||
void stream.writeln(JSON.stringify({ type: 'keepalive' }));
|
||||
}, KEEPALIVE_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
const recipients = await detectRecipientsFromEnvelope({
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
onProgress: (progress) => {
|
||||
void stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
pagesProcessed: progress.pagesProcessed,
|
||||
totalPages: progress.totalPages,
|
||||
recipientsDetected: progress.recipientsDetected,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Clear keepalive before sending final response
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'ai.detect-recipients.complete',
|
||||
envelopeId,
|
||||
userId: session.user.id,
|
||||
teamId: team.id,
|
||||
recipientCount: recipients.length,
|
||||
});
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'complete',
|
||||
recipients,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
// Clear keepalive on error
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
// The logger below it stringifies the error, using `console.error`
|
||||
// to attempt to get a stack trace
|
||||
console.error(error);
|
||||
|
||||
logger.error({
|
||||
event: 'ai.detect-recipients.error',
|
||||
error,
|
||||
});
|
||||
|
||||
const message = error instanceof AppError ? error.message : 'Failed to detect recipients';
|
||||
|
||||
await stream.writeln(
|
||||
JSON.stringify({
|
||||
type: 'error',
|
||||
message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle errors that occur before streaming starts
|
||||
logger.error({
|
||||
event: 'ai.detect-recipients.error',
|
||||
error,
|
||||
});
|
||||
|
||||
if (error instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json(body, status);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Failed to detect recipients' }, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
|
||||
|
||||
export const ZDetectRecipientsRequestSchema = z.object({
|
||||
envelopeId: z.string().min(1).describe('The ID of the envelope to detect recipients from.'),
|
||||
teamId: z.number().describe('The ID of the team the envelope belongs to.'),
|
||||
});
|
||||
|
||||
export type TDetectRecipientsRequest = z.infer<typeof ZDetectRecipientsRequestSchema>;
|
||||
|
||||
export const ZDetectRecipientsResponseSchema = z.object({
|
||||
recipients: z.array(ZDetectedRecipientSchema),
|
||||
});
|
||||
|
||||
export type TDetectRecipientsResponse = z.infer<typeof ZDetectRecipientsResponseSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { detectFieldsRoute } from './detect-fields';
|
||||
import { detectRecipientsRoute } from './detect-recipients';
|
||||
|
||||
export const aiRoute = new Hono<HonoEnv>()
|
||||
.route('/detect-recipients', detectRecipientsRoute)
|
||||
.route('/detect-fields', detectFieldsRoute);
|
||||
@@ -12,9 +12,11 @@ import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client';
|
||||
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { logger } from '@documenso/lib/utils/logger';
|
||||
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||
|
||||
import { aiRoute } from './api/ai/route';
|
||||
import { downloadRoute } from './api/download/download';
|
||||
import { filesRoute } from './api/files/files';
|
||||
import { type AppContext, appContext } from './context';
|
||||
@@ -50,6 +52,21 @@ const rateLimitMiddleware = rateLimiter({
|
||||
},
|
||||
});
|
||||
|
||||
const aiRateLimitMiddleware = rateLimiter({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
limit: 3, // 3 requests per window
|
||||
keyGenerator: (c) => {
|
||||
try {
|
||||
return getIpAddress(c.req.raw);
|
||||
} catch (error) {
|
||||
return 'unknown';
|
||||
}
|
||||
},
|
||||
message: {
|
||||
error: 'Too many requests, please try again later.',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Attach session and context to requests.
|
||||
*/
|
||||
@@ -85,6 +102,10 @@ app.route('/api/auth', auth);
|
||||
// Files route.
|
||||
app.route('/api/files', filesRoute);
|
||||
|
||||
// AI route.
|
||||
app.use('/api/ai/*', aiRateLimitMiddleware);
|
||||
app.route('/api/ai', aiRoute);
|
||||
|
||||
// API servers.
|
||||
app.use(`/api/v1/*`, cors());
|
||||
app.route('/api/v1', tsRestHonoApp);
|
||||
@@ -115,6 +136,8 @@ app.use(`${API_V2_BETA_URL}/*`, async (c) =>
|
||||
|
||||
// Start telemetry client for anonymous usage tracking.
|
||||
// Can be disabled by setting DOCUMENSO_DISABLE_TELEMETRY=true
|
||||
void TelemetryClient.start();
|
||||
if (env('NODE_ENV') !== 'development') {
|
||||
void TelemetryClient.start();
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -51,6 +51,7 @@ export default defineConfig({
|
||||
ssr: {
|
||||
noExternal: ['react-dropzone', 'plausible-tracker'],
|
||||
external: [
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/bcrypt',
|
||||
'@prisma/client',
|
||||
'@documenso/tailwind-config',
|
||||
@@ -64,6 +65,7 @@ export default defineConfig({
|
||||
include: ['prop-types', 'file-selector', 'attr-accept'],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/bcrypt',
|
||||
'@documenso/pdf-sign',
|
||||
'sharp',
|
||||
@@ -94,6 +96,7 @@ export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/bcrypt',
|
||||
'@documenso/pdf-sign',
|
||||
'@aws-sdk/cloudfront-signer',
|
||||
|
||||
@@ -70,6 +70,7 @@ COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/package-lock.json ./package-lock.json
|
||||
|
||||
COPY --from=builder /app/lingui.config.ts ./lingui.config.ts
|
||||
COPY --from=builder /app/patches ./patches
|
||||
|
||||
RUN npm ci
|
||||
|
||||
@@ -108,6 +109,8 @@ WORKDIR /app
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/out/json/ .
|
||||
# Copy the tailwind config files across
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/out/full/packages/tailwind-config ./packages/tailwind-config
|
||||
# Copy the patches across
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/patches ./patches
|
||||
|
||||
RUN npm ci --only=production
|
||||
|
||||
|
||||
Generated
+921
-757
File diff suppressed because it is too large
Load Diff
+7
-3
@@ -5,8 +5,9 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.5",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
"dev:remix": "turbo run dev --filter=@documenso/remix",
|
||||
@@ -63,7 +64,7 @@
|
||||
"lint-staged": "^16.2.7",
|
||||
"nanoid": "^5.1.6",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"pdfjs-dist": "5.4.449",
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"playwright": "1.56.1",
|
||||
@@ -83,19 +84,22 @@
|
||||
"zod-prisma-types": "3.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@documenso/pdf-sign": "^0.1.0",
|
||||
"@documenso/prisma": "*",
|
||||
"@lingui/conf": "^5.6.0",
|
||||
"@lingui/core": "^5.6.0",
|
||||
"ai": "^5.0.104",
|
||||
"inngest-cli": "^1.13.7",
|
||||
"luxon": "^3.7.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
"typescript": "5.6.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"overrides": {
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"pdfjs-dist": "5.4.449",
|
||||
"typescript": "5.6.2",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { APIRequestContext } from 'playwright-core';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import type { CreateEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||
import type { VerifyEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -17,18 +20,7 @@ test.describe('Embedding Presign API', () => {
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await createPresignToken(request, token);
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
@@ -54,19 +46,9 @@ test.describe('Embedding Presign API', () => {
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
expiresIn: 120, // 2 hours
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await createPresignToken(request, token, {
|
||||
expiresIn: 120, // 2 hours
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
@@ -92,19 +74,9 @@ test.describe('Embedding Presign API', () => {
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
expiresIn: 0, // Immediate expiration
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await createPresignToken(request, token, {
|
||||
expiresIn: 0, // Immediate expiration
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
@@ -129,18 +101,7 @@ test.describe('Embedding Presign API', () => {
|
||||
});
|
||||
|
||||
// First create a token
|
||||
const createResponse = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
},
|
||||
},
|
||||
);
|
||||
const createResponse = await createPresignToken(request, token);
|
||||
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createResponseData = await createResponse.json();
|
||||
@@ -150,18 +111,9 @@ test.describe('Embedding Presign API', () => {
|
||||
const presignToken = createResponseData.token;
|
||||
|
||||
// Then verify it
|
||||
const verifyResponse = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
token: presignToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
const verifyResponse = await verifyPresignToken(request, token, {
|
||||
token: presignToken,
|
||||
});
|
||||
|
||||
expect(verifyResponse.ok()).toBeTruthy();
|
||||
expect(verifyResponse.status()).toBe(200);
|
||||
@@ -183,18 +135,87 @@ test.describe('Embedding Presign API', () => {
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
token: 'invalid-token',
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await verifyPresignToken(request, token, {
|
||||
token: 'invalid-token',
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
console.log('Invalid token response:', responseData);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
expect(responseData.success).toBe(false);
|
||||
});
|
||||
|
||||
test('verifyEmbeddingPresignToken: should verify a valid scoped token', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// First create a token
|
||||
const createResponse = await createPresignToken(request, token, {
|
||||
scope: 'documentId:1',
|
||||
});
|
||||
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createResponseData = await createResponse.json();
|
||||
|
||||
console.log('Create response:', createResponseData);
|
||||
|
||||
const presignToken = createResponseData.token;
|
||||
|
||||
// Then verify it
|
||||
const verifyResponse = await verifyPresignToken(request, token, {
|
||||
token: presignToken,
|
||||
scope: 'documentId:1',
|
||||
});
|
||||
|
||||
expect(verifyResponse.ok()).toBeTruthy();
|
||||
expect(verifyResponse.status()).toBe(200);
|
||||
|
||||
const verifyResponseData = await verifyResponse.json();
|
||||
|
||||
console.log('Verify response:', verifyResponseData);
|
||||
|
||||
expect(verifyResponseData.success).toBe(true);
|
||||
});
|
||||
|
||||
test('verifyEmbeddingPresignToken: should reject a scope mismatched token', async ({
|
||||
request,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// First create a token
|
||||
const createResponse = await createPresignToken(request, token, {
|
||||
scope: 'documentId:1',
|
||||
});
|
||||
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createResponseData = await createResponse.json();
|
||||
|
||||
console.log('Create response:', createResponseData);
|
||||
|
||||
const presignToken = createResponseData.token;
|
||||
|
||||
// Then verify it
|
||||
const response = await verifyPresignToken(request, token, {
|
||||
token: presignToken,
|
||||
scope: 'documentId:2',
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
@@ -206,3 +227,40 @@ test.describe('Embedding Presign API', () => {
|
||||
expect(responseData.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const createPresignToken = async (
|
||||
request: APIRequestContext,
|
||||
apiToken: string,
|
||||
data?: Partial<CreateEmbeddingPresignTokenOptions>,
|
||||
) => {
|
||||
return await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken,
|
||||
...data,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const verifyPresignToken = async (
|
||||
request: APIRequestContext,
|
||||
apiToken: string,
|
||||
data: VerifyEmbeddingPresignTokenOptions,
|
||||
) => {
|
||||
return await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { pick } from 'remeda';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentDistributionMethod,
|
||||
@@ -23,7 +24,9 @@ import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||
import type { TUpdateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||
|
||||
@@ -144,6 +147,9 @@ test.describe('API V2 Envelopes', () => {
|
||||
externalId: 'externalId',
|
||||
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
globalAccessAuth: ['ACCOUNT'],
|
||||
// Ignore this error in the test since it doesn't actually exist in the PDF:
|
||||
// - Error setting value for field hello: PDFDocument has no form field with the name "hello"
|
||||
// We want to check if the form value is set in the DB.
|
||||
formValues: {
|
||||
hello: 'world',
|
||||
},
|
||||
@@ -262,8 +268,6 @@ test.describe('API V2 Envelopes', () => {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(userB.email);
|
||||
|
||||
expect(envelope.envelopeItems.length).toBe(2);
|
||||
expect(envelope.envelopeItems[0].title).toBe('field-meta.pdf');
|
||||
expect(envelope.envelopeItems[1].title).toBe('field-font-alignment.pdf');
|
||||
@@ -557,4 +561,543 @@ test.describe('API V2 Envelopes', () => {
|
||||
userEmail: userA.email,
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Empty recipient tests', () => {
|
||||
test('Create template envelope with empty email recipient', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title: 'Template with Empty Email Recipient',
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const response = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Create recipient with empty email
|
||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId: response.id,
|
||||
data: [
|
||||
{
|
||||
email: '',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
||||
expect(createRecipientsRes.status()).toBe(200);
|
||||
|
||||
const recipientsResponse = await createRecipientsRes.json();
|
||||
const recipient = recipientsResponse.data[0];
|
||||
|
||||
expect(recipient.email).toBe('');
|
||||
expect(recipient.name).toBe('Test Recipient');
|
||||
|
||||
// Get envelope items to assign fields
|
||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${response.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
// Create field for the recipient with empty email
|
||||
const createFieldsRequest = {
|
||||
envelopeId: response.id,
|
||||
data: [
|
||||
{
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 100,
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createFieldsRequest,
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
expect(createFieldsRes.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('Create document envelope with empty email recipient', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Document with Empty Email Recipient',
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const response = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Create recipient with empty email
|
||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId: response.id,
|
||||
data: [
|
||||
{
|
||||
email: '',
|
||||
name: 'Document Recipient No Email',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
||||
const recipientsResponse = await createRecipientsRes.json();
|
||||
const recipient = recipientsResponse.data[0];
|
||||
|
||||
expect(recipient.email).toBe('');
|
||||
expect(recipient.name).toBe('Document Recipient No Email');
|
||||
});
|
||||
|
||||
test('Update recipient to have empty email', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title: 'Update Recipient Email Test',
|
||||
recipients: [
|
||||
{
|
||||
email: userA.email,
|
||||
name: 'Test User',
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
],
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Get the envelope to get recipient ID
|
||||
const getRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const envelope: TGetEnvelopeResponse = await getRes.json();
|
||||
const recipientId = envelope.recipients[0].id;
|
||||
|
||||
// Update recipient to have empty email
|
||||
const updateRequest: TUpdateEnvelopeRecipientsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: [
|
||||
{
|
||||
id: recipientId,
|
||||
email: '',
|
||||
name: 'Updated Name No Email',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const updateRes = await request.post(`${baseUrl}/envelope/recipient/update-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: updateRequest,
|
||||
});
|
||||
|
||||
expect(updateRes.ok()).toBeTruthy();
|
||||
const updateResponse = await updateRes.json();
|
||||
const updatedRecipient = updateResponse.data[0];
|
||||
|
||||
expect(updatedRecipient.email).toBe('');
|
||||
expect(updatedRecipient.name).toBe('Updated Name No Email');
|
||||
});
|
||||
|
||||
test('Mixed recipients with and without emails', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title: 'Mixed Recipients Test',
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const response = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Create multiple recipients, some with email, some without
|
||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId: response.id,
|
||||
data: [
|
||||
{
|
||||
email: userA.email,
|
||||
name: 'Recipient With Email',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
{
|
||||
email: '',
|
||||
name: 'Recipient Without Email 1',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
{
|
||||
email: userB.email,
|
||||
name: 'Another With Email',
|
||||
role: RecipientRole.APPROVER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
{
|
||||
email: '',
|
||||
name: 'Recipient Without Email 2',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
||||
const recipientsResponse = await createRecipientsRes.json();
|
||||
const recipients = recipientsResponse.data;
|
||||
|
||||
expect(recipients.length).toBe(4);
|
||||
expect(recipients[0].email).toBe(userA.email.toLowerCase());
|
||||
expect(recipients[1].email).toBe('');
|
||||
expect(recipients[2].email).toBe(userB.email.toLowerCase());
|
||||
expect(recipients[3].email).toBe('');
|
||||
|
||||
// Get envelope to assign fields
|
||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${response.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
// Create fields for all recipients including those without emails
|
||||
const createFieldsRequest = {
|
||||
envelopeId: response.id,
|
||||
data: recipients.map((recipient, index) => ({
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 0 + index,
|
||||
width: 50,
|
||||
height: 50,
|
||||
})),
|
||||
};
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createFieldsRequest,
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Distribute envelope with empty email recipients', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Document for Distribution with Empty Email',
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Create recipients with empty emails
|
||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: [
|
||||
{
|
||||
email: '',
|
||||
name: 'Recipient One',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
{
|
||||
email: '',
|
||||
name: 'Recipient Two',
|
||||
role: RecipientRole.APPROVER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
||||
const recipientsResponse = await createRecipientsRes.json();
|
||||
const recipients = recipientsResponse.data;
|
||||
|
||||
// Get envelope to assign fields
|
||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
// Create fields for recipients
|
||||
const createFieldsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: recipients.map((recipient, index) => ({
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 0 + index,
|
||||
width: 50,
|
||||
height: 50,
|
||||
})),
|
||||
};
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createFieldsRequest,
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
// Distribute the envelope
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: createResponse.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
const distributeResponse = await distributeRes.json();
|
||||
expect(distributeResponse.success).toBe(true);
|
||||
expect(distributeResponse.id).toBe(createResponse.id);
|
||||
expect(distributeResponse.recipients).toHaveLength(2);
|
||||
|
||||
// Verify recipients have empty emails and signing URLs
|
||||
expect(distributeResponse.recipients[0].email).toBe('');
|
||||
expect(distributeResponse.recipients[0].signingUrl).toBeTruthy();
|
||||
expect(distributeResponse.recipients[1].email).toBe('');
|
||||
expect(distributeResponse.recipients[1].signingUrl).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Distribute envelope with empty email recipient and auth requirements fails', async ({
|
||||
request,
|
||||
}) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Document with Auth Requirements',
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Create recipient with empty email and TWO_FACTOR_AUTH action auth
|
||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: [
|
||||
{
|
||||
email: '',
|
||||
name: 'Recipient With Auth',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [DocumentAccessAuth.TWO_FACTOR_AUTH],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
||||
const recipientsResponse = await createRecipientsRes.json();
|
||||
const recipient = recipientsResponse.data[0];
|
||||
|
||||
// Get envelope to assign fields
|
||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
// Create field for the recipient
|
||||
const createFieldsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: [
|
||||
{
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 100,
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createFieldsRequest,
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
// Try to distribute the envelope - should fail
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: createResponse.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Expect distribution to fail
|
||||
expect(distributeRes.ok()).toBeFalsy();
|
||||
expect(distributeRes.status()).toBe(400);
|
||||
|
||||
const errorResponse = await distributeRes.json();
|
||||
expect(errorResponse.message).toContain('requires an email');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3103,7 +3103,7 @@ test.describe('Document API V2', () => {
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope use endpoint', async ({ page, request }) => {
|
||||
test('should allow authorized access to envelope use endpoint', async ({ request }) => {
|
||||
const doc = await seedTemplate({
|
||||
title: 'Team template 1',
|
||||
userId: userA.id,
|
||||
@@ -4313,5 +4313,62 @@ test.describe('Document API V2', () => {
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope audit logs endpoint', () => {
|
||||
test('should block unauthorized access to envelope audit logs endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/${doc.id}/audit-log`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope audit logs endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Add a recipient which will trigger an audit log.
|
||||
await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
data: [
|
||||
{
|
||||
name: 'Test',
|
||||
email: 'test@example.com',
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/${doc.id}/audit-log`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
expect(data.count).toEqual(1);
|
||||
expect(data.data[0].type).toEqual('RECIPIENT_CREATED');
|
||||
expect(data.currentPage).toBeGreaterThanOrEqual(1);
|
||||
expect(data.perPage).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,9 +89,8 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(3);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
expect(retrievedFields[2].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields.filter((field) => field.type === 'SIGNATURE')).toHaveLength(2);
|
||||
expect(retrievedFields.filter((field) => field.type === 'TEXT')).toHaveLength(1);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
@@ -214,10 +213,8 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(4);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
expect(retrievedFields[2].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[3].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields.filter((field) => field.type === 'SIGNATURE')).toHaveLength(3);
|
||||
expect(retrievedFields.filter((field) => field.type === 'TEXT')).toHaveLength(1);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
@@ -259,10 +256,16 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(2);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
const textField = retrievedFields.find((field) => field.type === 'TEXT');
|
||||
const signatureField = retrievedFields.find((field) => field.type === 'SIGNATURE');
|
||||
|
||||
expect(signatureField).toBeDefined();
|
||||
expect(textField).toBeDefined();
|
||||
|
||||
if (!signatureField || !textField) {
|
||||
throw new Error('No signature or text field');
|
||||
}
|
||||
|
||||
const textField = retrievedFields[1];
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
if (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
@@ -83,14 +84,13 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
// delete document
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
@@ -128,14 +128,13 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
// delete document
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
@@ -169,19 +168,17 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Draft' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// delete document
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible(); // Required to reduce flakiness.
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
await expectToastTextToBeVisible(page, 'Document deleted');
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||
|
||||
@@ -203,14 +200,13 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
// Delete document.
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
@@ -239,14 +235,13 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
// Delete document.
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
@@ -292,36 +287,24 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await expect(async () => {
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
|
||||
}).toPass();
|
||||
const completedDocActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, completedDocActionBtn);
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
|
||||
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
|
||||
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await expect(async () => {
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
|
||||
}).toPass();
|
||||
const pendingDocActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, pendingDocActionBtn);
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
|
||||
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
|
||||
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
|
||||
export const expectTextToBeVisible = async (page: Page, text: string) => {
|
||||
@@ -7,3 +8,22 @@ export const expectTextToBeVisible = async (page: Page, text: string) => {
|
||||
export const expectTextToNotBeVisible = async (page: Page, text: string) => {
|
||||
await expect(page.getByText(text).first()).not.toBeVisible();
|
||||
};
|
||||
|
||||
export const expectToastTextToBeVisible = async (page: Page, text: string) => {
|
||||
await expect(page.locator('[role="status"]').getByText(text)).toBeVisible();
|
||||
};
|
||||
|
||||
export const openDropdownMenu = async (page: Page, dropdownButton: Locator) => {
|
||||
await page.waitForTimeout(500); // Initial timeout incase table remounts which will close the dropdown.
|
||||
await dropdownButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await dropdownButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await expect(page.getByRole('menuitem').first()).toBeVisible();
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||
import { expectTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
@@ -117,7 +117,9 @@ test('[TEAMS]: can pin a document folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||
|
||||
await page.reload();
|
||||
@@ -142,7 +144,9 @@ test('[TEAMS]: can unpin a document folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Unpin' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
||||
|
||||
await page.reload();
|
||||
@@ -166,7 +170,9 @@ test('[TEAMS]: can rename a document folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await page.getByLabel('Name').fill('Team Archive');
|
||||
@@ -191,7 +197,9 @@ test('[TEAMS]: document folder visibility is visible to team member', async ({ p
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toBeVisible();
|
||||
@@ -220,7 +228,9 @@ test('[TEAMS]: document folder can be moved to another document folder', async (
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button').nth(0);
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Team Clients' }).click();
|
||||
@@ -271,7 +281,9 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
|
||||
await page.getByRole('textbox').fill(`delete ${folder.name}`);
|
||||
@@ -280,12 +292,8 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
|
||||
await page.goto(`/t/${team.url}/documents`);
|
||||
|
||||
await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
|
||||
await expect(page.getByText(proposal.title)).not.toBeVisible();
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText(report.title)).not.toBeVisible();
|
||||
await expect(page.locator(`[data-folder-id="${reportsFolder.id}"]`)).not.toBeVisible();
|
||||
await expect(page.getByText(proposal.title)).toBeVisible();
|
||||
await expect(page.getByText(report.title)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => {
|
||||
@@ -410,7 +418,9 @@ test('[TEAMS]: can pin a template folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||
|
||||
await page.reload();
|
||||
@@ -436,7 +446,9 @@ test('[TEAMS]: can unpin a template folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Unpin' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
||||
|
||||
await page.reload();
|
||||
@@ -462,7 +474,9 @@ test('[TEAMS]: can rename a template folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await page.getByLabel('Name').fill('Updated Team Template Folder');
|
||||
@@ -488,7 +502,9 @@ test('[TEAMS]: template folder visibility is not visible to team member', async
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
|
||||
@@ -519,7 +535,9 @@ test('[TEAMS]: template folder can be moved to another template folder', async (
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button').nth(0);
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Team Client Templates' }).click();
|
||||
@@ -572,7 +590,9 @@ test('[TEAMS]: template folder can be deleted', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
|
||||
await page.getByRole('textbox').fill(`delete ${folder.name}`);
|
||||
@@ -761,7 +781,9 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/`);
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn1 = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn1);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Admins only');
|
||||
@@ -781,7 +803,9 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
|
||||
|
||||
await page.goto(`/t/${team.url}/documents`);
|
||||
|
||||
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||
const folderMoreBtn2 = page.getByTestId('folder-card-more-button').nth(0);
|
||||
await openDropdownMenu(page, folderMoreBtn2);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Managers and above');
|
||||
@@ -801,7 +825,9 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/`);
|
||||
|
||||
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||
const folderMoreBtn3 = page.getByTestId('folder-card-more-button').nth(0);
|
||||
await openDropdownMenu(page, folderMoreBtn3);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Everyone');
|
||||
@@ -966,7 +992,9 @@ test('[TEAMS]: team member can move documents to everyone folder', async ({ page
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1018,7 +1046,9 @@ test('[TEAMS]: team manager can move manager document to manager folder', async
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1071,7 +1101,9 @@ test('[TEAMS]: team manager can move manager document to everyone folder', async
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1124,7 +1156,9 @@ test('[TEAMS]: team manager can move everyone document to manager folder', async
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1177,7 +1211,9 @@ test('[TEAMS]: team admin can move admin document to admin folder', async ({ pag
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1228,7 +1264,9 @@ test('[TEAMS]: team admin can move admin document to manager folder', async ({ p
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1279,7 +1317,9 @@ test('[TEAMS]: team admin can move admin document to everyone folder', async ({
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1330,7 +1370,9 @@ test('[TEAMS]: team admin can move manager document to admin folder', async ({ p
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1381,7 +1423,9 @@ test('[TEAMS]: team admin can move manager document to manager folder', async ({
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1432,7 +1476,9 @@ test('[TEAMS]: team admin can move manager document to everyone folder', async (
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1483,7 +1529,9 @@ test('[TEAMS]: team admin can move everyone document to admin folder', async ({
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1534,7 +1582,9 @@ test('[TEAMS]: team admin can move everyone document to manager folder', async (
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1585,7 +1635,9 @@ test('[TEAMS]: team admin can move everyone document to everyone folder', async
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1630,7 +1682,9 @@ test('[TEAMS]: team owner can move admin document to admin folder', async ({ pag
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1675,7 +1729,9 @@ test('[TEAMS]: team owner can move admin document to manager folder', async ({ p
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1720,7 +1776,9 @@ test('[TEAMS]: team owner can move admin document to everyone folder', async ({
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1765,7 +1823,9 @@ test('[TEAMS]: team owner can move manager document to admin folder', async ({ p
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1810,7 +1870,9 @@ test('[TEAMS]: team owner can move manager document to manager folder', async ({
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1855,7 +1917,9 @@ test('[TEAMS]: team owner can move manager document to everyone folder', async (
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1900,7 +1964,9 @@ test('[TEAMS]: team owner can move everyone document to admin folder', async ({
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1945,7 +2011,9 @@ test('[TEAMS]: team owner can move everyone document to manager folder', async (
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1990,7 +2058,9 @@ test('[TEAMS]: team owner can move everyone document to everyone folder', async
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
|
||||
@@ -6,7 +6,11 @@ import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectTextToBeVisible, expectTextToNotBeVisible } from '../fixtures/generic';
|
||||
import {
|
||||
expectTextToBeVisible,
|
||||
expectTextToNotBeVisible,
|
||||
openDropdownMenu,
|
||||
} from '../fixtures/generic';
|
||||
|
||||
test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
@@ -399,7 +403,9 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
await expect(page.getByText('Team members have been added').first()).toBeVisible();
|
||||
|
||||
// Update CUSTOM_GROUP_B
|
||||
await page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button').click();
|
||||
const updateBtn = page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button');
|
||||
await openDropdownMenu(page, updateBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Update role' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Update role' }).click();
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Team Admin' }).click();
|
||||
@@ -409,7 +415,9 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
await page.reload();
|
||||
|
||||
// Delete CUSTOM_GROUP_B
|
||||
await page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button').click();
|
||||
const deleteBtn = page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button');
|
||||
await openDropdownMenu(page, deleteBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expectTextToBeVisible(page, 'You have successfully removed this group from the team.');
|
||||
@@ -477,7 +485,9 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
|
||||
await expect(page.getByText(user2.email)).toBeVisible();
|
||||
await expect(page.getByText(user3.email)).toBeVisible();
|
||||
|
||||
await page.getByRole('row', { name: user3.email }).getByRole('button').click();
|
||||
const inviteActionBtn = page.getByRole('row', { name: user3.email }).getByRole('button');
|
||||
await openDropdownMenu(page, inviteActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
await expect(page.getByText('Invitation has been deleted').first()).toBeVisible();
|
||||
await expect(page.getByText(user3.email)).not.toBeVisible();
|
||||
@@ -508,7 +518,9 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
|
||||
await expect(page.getByText(user.email)).toBeVisible();
|
||||
await expect(page.getByText(user2.email)).toBeVisible();
|
||||
|
||||
await page.getByRole('row', { name: user2.email }).getByRole('button').click();
|
||||
const memberActionBtn = page.getByRole('row', { name: user2.email }).getByRole('button');
|
||||
await openDropdownMenu(page, memberActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
await expect(page.getByText('You have successfully removed').first()).toBeVisible();
|
||||
@@ -522,7 +534,9 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
|
||||
await expect(page.getByText(user.email)).toBeVisible();
|
||||
await expect(page.getByText(user2.email)).toBeVisible();
|
||||
|
||||
await page.getByRole('row', { name: user2.email }).getByRole('button').click();
|
||||
const orgMemberBtn = page.getByRole('row', { name: user2.email }).getByRole('button');
|
||||
await openDropdownMenu(page, orgMemberBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(page.getByText('You have successfully removed this user').first()).toBeVisible();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
@@ -44,6 +46,9 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
.fill('public-direct-template-description');
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Wait for toast
|
||||
await expectToastTextToBeVisible(page, 'Template has been updated');
|
||||
|
||||
// Check that public profile is disabled.
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
||||
await expect(page.locator('body')).toContainText('404 Profile not found');
|
||||
@@ -51,7 +56,21 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
// Go back to public profile page.
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
|
||||
await page.getByRole('switch').click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Expect profile to be enabled via db.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const profile = await prisma.teamProfile.findFirst({
|
||||
where: { teamId: team.id },
|
||||
});
|
||||
return profile?.enabled;
|
||||
},
|
||||
{
|
||||
timeout: 1000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
// Assert values.
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
||||
|
||||
@@ -11,7 +11,11 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||
import {
|
||||
expectTextToBeVisible,
|
||||
expectToastTextToBeVisible,
|
||||
openDropdownMenu,
|
||||
} from '../fixtures/generic';
|
||||
|
||||
test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
|
||||
@@ -239,21 +243,15 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
await page.getByRole('menuitem').filter({ hasText: 'Resend' }).click();
|
||||
const actionBtn = page.getByTestId('document-table-action-btn').first();
|
||||
await expect(actionBtn).toBeAttached();
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Resend' }).click();
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('status').filter({ hasText: 'Document re-sent' }).first(),
|
||||
).toBeVisible();
|
||||
await expectToastTextToBeVisible(page, 'Document re-sent');
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
@@ -265,14 +263,12 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
const actionBtn = page.getByTestId('document-table-action-btn').first();
|
||||
await expect(actionBtn).toBeVisible({
|
||||
timeout: 500,
|
||||
});
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
@@ -309,14 +305,12 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
const actionBtn = page.getByTestId('document-table-action-btn').first();
|
||||
await expect(actionBtn).toBeVisible({
|
||||
timeout: 500,
|
||||
});
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click({ force: true });
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click({ force: true });
|
||||
@@ -354,14 +348,12 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
const actionBtn = page.getByTestId('document-table-action-btn').first();
|
||||
await expect(actionBtn).toBeVisible({
|
||||
timeout: 500,
|
||||
});
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click({ force: true });
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click({ force: true });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test('[TEAMS]: send team email request', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
@@ -54,8 +55,13 @@ test('[TEAMS]: delete team email', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click();
|
||||
const settingsBtn = page
|
||||
.locator('section div')
|
||||
.filter({ hasText: 'Team email' })
|
||||
.getByRole('button');
|
||||
await openDropdownMenu(page, settingsBtn);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
|
||||
@@ -296,7 +296,13 @@ test.describe('AutoSave Fields Step', () => {
|
||||
['SIGNATURE', 'TEXT'].toSorted(),
|
||||
);
|
||||
|
||||
const textField = fields[1];
|
||||
const textField = fields.find((field) => field.type === 'TEXT');
|
||||
expect(textField).toBeDefined();
|
||||
|
||||
if (!textField) {
|
||||
throw new Error('No text field');
|
||||
}
|
||||
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
if (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test('[TEMPLATES]: view templates', async ({ page }) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
@@ -71,13 +72,14 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||
});
|
||||
|
||||
for (const template of ['Team template 1', 'Team template 2']) {
|
||||
await page
|
||||
const actionBtn = page
|
||||
.getByRole('row', { name: template })
|
||||
.getByRole('cell', { name: 'Use Template' })
|
||||
.getByRole('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
.nth(1);
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||
@@ -110,7 +112,9 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Duplicate team template.
|
||||
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||
const actionBtn = page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1);
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expect(page.getByText('Template duplicated').first()).toBeVisible();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||
import { expectTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
/**
|
||||
* Helper function to seed a webhook directly in the database for testing.
|
||||
@@ -147,9 +147,11 @@ test('[WEBHOOKS]: delete webhook', async ({ page }) => {
|
||||
|
||||
// Find the row with the webhook and click the action dropdown
|
||||
const webhookRow = page.locator('tr', { hasText: webhookUrl });
|
||||
await webhookRow.getByTestId('webhook-table-action-btn').click();
|
||||
const actionBtn = webhookRow.getByTestId('webhook-table-action-btn');
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
|
||||
// Click Delete menu item
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
|
||||
// Fill in confirmation field
|
||||
@@ -196,9 +198,11 @@ test('[WEBHOOKS]: update webhook', async ({ page }) => {
|
||||
|
||||
// Find the row with the webhook and click the action dropdown
|
||||
const webhookRow = page.locator('tr', { hasText: originalWebhookUrl });
|
||||
await webhookRow.getByTestId('webhook-table-action-btn').click();
|
||||
const actionBtn = webhookRow.getByTestId('webhook-table-action-btn');
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
|
||||
// Click Edit menu item
|
||||
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
"scripts": {
|
||||
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
|
||||
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
|
||||
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
|
||||
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test NEXT_PRIVATE_LOGGER_FILE_PATH=./logs.json start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"devDependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@napi-rs/canvas": "^0.1.82",
|
||||
"@napi-rs/canvas": "^0.1.83",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@types/node": "^20",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
function calculateWorkers() {
|
||||
const total = os.cpus().length;
|
||||
|
||||
// Reserve 2 cores for the system
|
||||
const usable = Math.max(total - 2, 1);
|
||||
|
||||
// 1 worker per 2 cores, minimum 1
|
||||
const workers = Math.max(Math.floor(usable / 2), 1);
|
||||
|
||||
// Max 6 workers
|
||||
return Math.min(workers, 6);
|
||||
}
|
||||
|
||||
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||
|
||||
ENV_FILES.forEach((file) => {
|
||||
@@ -15,9 +29,8 @@ ENV_FILES.forEach((file) => {
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
workers: 2,
|
||||
fullyParallel: true,
|
||||
workers: 10, // See Projects where 10 is utilized for API tests. We're not running 10 workers for UI tests.
|
||||
maxFailures: process.env.CI ? 1 : undefined,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
@@ -31,25 +44,54 @@ export default defineConfig({
|
||||
baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on',
|
||||
|
||||
video: 'on-first-retry',
|
||||
trace: 'retain-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
|
||||
/* Add explicit timeouts for actions */
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
|
||||
contextOptions: {
|
||||
reducedMotion: 'reduce',
|
||||
},
|
||||
|
||||
/* Disable animations via cookie for more stable tests */
|
||||
storageState: {
|
||||
cookies: [
|
||||
{
|
||||
name: '__disable_animations',
|
||||
value: 'true',
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
expires: -1,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: 'Lax' as const,
|
||||
},
|
||||
],
|
||||
origins: [],
|
||||
},
|
||||
},
|
||||
|
||||
timeout: 60_000,
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
// API Tests e2e/api/**/*.spec.ts
|
||||
{
|
||||
name: 'chromium',
|
||||
name: 'api',
|
||||
testMatch: /e2e\/api\/.*\.spec\.ts/,
|
||||
workers: 10, // Limited by DB connections before it gets flakey.
|
||||
},
|
||||
// Run UI Tests
|
||||
{
|
||||
name: 'ui',
|
||||
testMatch: /e2e\/(?!api\/).*\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1200 },
|
||||
},
|
||||
workers: calculateWorkers(),
|
||||
},
|
||||
|
||||
// {
|
||||
|
||||
@@ -32,17 +32,17 @@
|
||||
"@react-email/img": "0.0.11",
|
||||
"@react-email/link": "0.0.12",
|
||||
"@react-email/preview": "0.0.13",
|
||||
"@react-email/render": "0.0.17",
|
||||
"@react-email/render": "2.0.0",
|
||||
"@react-email/row": "0.0.12",
|
||||
"@react-email/section": "0.0.16",
|
||||
"@react-email/tailwind": "^2.0.1",
|
||||
"@react-email/text": "0.1.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"react-email": "^5.0.4",
|
||||
"react-email": "^5.0.6",
|
||||
"resend": "^6.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/nodemailer": "^6.4.21"
|
||||
"@types/nodemailer": "^7.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export type RenderOptions = ReactEmail.Options & {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const colors = (config.theme?.extend?.colors || {}) as Record<string, string>;
|
||||
|
||||
export const render = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
export const render = async (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return ReactEmail.render(
|
||||
@@ -36,7 +36,7 @@ export const render = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const renderWithI18N = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
export const renderWithI18N = async (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, i18n, ...otherOptions } = options ?? {};
|
||||
|
||||
if (!i18n) {
|
||||
@@ -62,24 +62,3 @@ export const renderWithI18N = (element: React.ReactNode, options?: RenderOptions
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
export const renderAsync = async (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return await ReactEmail.renderAsync(
|
||||
<BrandingProvider branding={branding}>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
</Tailwind>
|
||||
</BrandingProvider>,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -132,7 +132,12 @@ export const EnvelopeEditorProvider = ({
|
||||
});
|
||||
|
||||
const envelopeFieldSetMutationQuery = trpc.envelope.field.set.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: ({ data: fields }) => {
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
fields,
|
||||
}));
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -154,8 +159,18 @@ export const EnvelopeEditorProvider = ({
|
||||
setEnvelope((prev) => ({
|
||||
...prev,
|
||||
recipients,
|
||||
fields: prev.fields.filter((field) =>
|
||||
recipients.some((recipient) => recipient.id === field.recipientId),
|
||||
),
|
||||
}));
|
||||
|
||||
// Reset the local fields to ensure deleted recipient fields are removed.
|
||||
editorFields.resetForm(
|
||||
envelope.fields.filter((field) =>
|
||||
recipients.some((recipient) => recipient.id === field.recipientId),
|
||||
),
|
||||
);
|
||||
|
||||
setAutosaveError(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -265,7 +280,7 @@ export const EnvelopeEditorProvider = ({
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch and sycn the envelope back into the editor.
|
||||
* Fetch and sync the envelope back into the editor.
|
||||
*
|
||||
* Overrides everything.
|
||||
*/
|
||||
|
||||
@@ -18,3 +18,6 @@ export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@docume
|
||||
|
||||
export const USE_INTERNAL_URL_BROWSERLESS = () =>
|
||||
env('NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS') === 'true';
|
||||
|
||||
export const IS_AI_FEATURES_CONFIGURED = () =>
|
||||
!!env('GOOGLE_VERTEX_PROJECT_ID') && !!env('GOOGLE_VERTEX_API_KEY');
|
||||
|
||||
@@ -6,6 +6,7 @@ export const SUPPORTED_LANGUAGE_CODES = [
|
||||
'fr',
|
||||
'es',
|
||||
'it',
|
||||
'nl',
|
||||
'pl',
|
||||
'pt-BR',
|
||||
'ja',
|
||||
@@ -61,6 +62,10 @@ export const SUPPORTED_LANGUAGES: Record<string, SupportedLanguage> = {
|
||||
full: 'Italian',
|
||||
short: 'it',
|
||||
},
|
||||
nl: {
|
||||
short: 'nl',
|
||||
full: 'Dutch',
|
||||
},
|
||||
pl: {
|
||||
short: 'pl',
|
||||
full: 'Polish',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/cli
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
@@ -77,7 +78,8 @@ export const run = async ({
|
||||
const recipientsToNotify = envelope.recipients.filter(
|
||||
(recipient) =>
|
||||
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
|
||||
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||
recipient.signingStatus !== SigningStatus.REJECTED &&
|
||||
isRecipientEmailValidForSending(recipient),
|
||||
);
|
||||
|
||||
await io.runTask('send-cancellation-emails', async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
|
||||
@@ -79,8 +80,8 @@ export const run = async ({
|
||||
|
||||
const recipientReference = recipientName || recipientEmail;
|
||||
|
||||
// Don't send notification if the owner is the one who signed
|
||||
if (owner.email === recipientEmail) {
|
||||
// Don't send notification if the owner is the one who signed.
|
||||
if (owner.email === recipientEmail || !isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
|
||||
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
|
||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
@@ -85,36 +86,38 @@ export const run = async ({
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
// Send confirmation email to the recipient who rejected
|
||||
await io.runTask('send-rejection-confirmation-email', async () => {
|
||||
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
|
||||
recipientName: recipient.name,
|
||||
documentName: envelope.title,
|
||||
documentOwnerName: envelope.user.name || envelope.user.email,
|
||||
reason: recipient.rejectionReason || '',
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
if (isRecipientEmailValidForSending(recipient)) {
|
||||
await io.runTask('send-rejection-confirmation-email', async () => {
|
||||
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
|
||||
recipientName: recipient.name,
|
||||
documentName: envelope.title,
|
||||
documentOwnerName: envelope.user.name || envelope.user.email,
|
||||
reason: recipient.rejectionReason || '',
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(recipientTemplate, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(recipientTemplate, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
|
||||
html,
|
||||
text,
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification email to document owner
|
||||
await io.runTask('send-owner-notification-email', async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
|
||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
@@ -177,31 +178,33 @@ export const run = async ({
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
});
|
||||
|
||||
await io.runTask('send-signing-email', async () => {
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
if (isRecipientEmailValidForSending(recipient)) {
|
||||
await io.runTask('send-signing-email', async () => {
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: renderCustomEmailTemplate(
|
||||
documentMeta?.subject || emailSubject,
|
||||
customEmailTemplate,
|
||||
),
|
||||
html,
|
||||
text,
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: renderCustomEmailTemplate(
|
||||
documentMeta?.subject || emailSubject,
|
||||
customEmailTemplate,
|
||||
),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await io.runTask('update-recipient', async () => {
|
||||
await prisma.recipient.update({
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google-vertex": "3.0.81",
|
||||
"@aws-sdk/client-s3": "^3.936.0",
|
||||
"@aws-sdk/client-sesv2": "^3.936.0",
|
||||
"@aws-sdk/cloudfront-signer": "^3.935.0",
|
||||
@@ -27,6 +28,7 @@
|
||||
"@lingui/core": "^5.6.0",
|
||||
"@lingui/macro": "^5.6.0",
|
||||
"@lingui/react": "^5.6.0",
|
||||
"@napi-rs/canvas": "^0.1.83",
|
||||
"@noble/ciphers": "0.6.0",
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@node-rs/bcrypt": "^1.10.7",
|
||||
@@ -35,6 +37,7 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@team-plain/typescript-sdk": "^5.11.0",
|
||||
"@vvo/tzdb": "^6.196.0",
|
||||
"ai": "^5.0.104",
|
||||
"csv-parse": "^6.1.0",
|
||||
"inngest": "^3.45.1",
|
||||
"jose": "^6.1.2",
|
||||
@@ -43,6 +46,7 @@
|
||||
"luxon": "^3.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"oslo": "^0.17.0",
|
||||
"p-map": "^7.0.4",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
@@ -63,4 +67,4 @@
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/pg": "^8.15.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user