Compare commits

..

4 Commits

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

Before

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "^15.5.7"
"next": "^15"
},
"devDependencies": {
"@types/node": "^20",
@@ -45,7 +45,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
toast({
title: _(msg`Document deleted`),
description: _(msg`The Document has been deleted successfully.`),
description: 'The Document has been deleted successfully.',
duration: 5000,
});
@@ -54,9 +54,8 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to delete your document. Please try again later.`,
),
description:
'We encountered an unknown error while attempting to delete your document. Please try again later.',
});
}
};
@@ -1,141 +0,0 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useCurrentTeam } from '~/providers/team';
type AiFeaturesEnableDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onEnabled: () => void;
};
export const AiFeaturesEnableDialog = ({
open,
onOpenChange,
onEnabled,
}: AiFeaturesEnableDialogProps) => {
const { t } = useLingui();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const isTeamAdmin = team.currentTeamRole === TeamMemberRole.ADMIN;
const isOrganisationAdmin = organisation.currentOrganisationRole === OrganisationMemberRole.ADMIN;
const canEnableAiFeatures = isTeamAdmin || isOrganisationAdmin;
const [error, setError] = useState<string | null>(null);
const { mutateAsync: updateTeamSettings, isPending: isUpdatingTeamSettings } =
trpc.team.settings.update.useMutation();
const { mutateAsync: updateOrganisationSettings, isPending: isUpdatingOrganisationSettings } =
trpc.organisation.settings.update.useMutation();
const isSubmitting = isUpdatingTeamSettings || isUpdatingOrganisationSettings;
const onEnableClick = async () => {
if (!canEnableAiFeatures) {
return;
}
setError(null);
try {
if (isTeamAdmin) {
await updateTeamSettings({
teamId: team.id,
data: { aiFeaturesEnabled: true },
});
} else {
await updateOrganisationSettings({
organisationId: organisation.id,
data: { aiFeaturesEnabled: true },
});
}
onEnabled();
onOpenChange(false);
} catch (err) {
console.error('Failed to enable AI features', err);
setError(
err instanceof Error
? err.message
: t`We couldn't enable AI features right now. Please try again.`,
);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
<Trans>Enable AI features</Trans>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>
Turn on AI detection to automatically find recipients and fields in your documents. AI
providers do not retain your data for training.
</Trans>
</p>
<Alert variant="neutral">
<AlertDescription>
<Trans>
Your document content will be sent securely to our AI provider solely for detection
and will not be stored or used for training.
</Trans>
</AlertDescription>
</Alert>
{canEnableAiFeatures ? (
<p className="text-sm text-muted-foreground">
<Trans>
You're an admin. You can enable AI features for this team right away. Everyone on
the team will see AI detection once enabled.
</Trans>
</p>
) : (
<p className="text-sm text-muted-foreground">
<Trans>
AI features are disabled for your team. Please ask your team owner or organisation
owner to enable them.
</Trans>
</p>
)}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
{canEnableAiFeatures ? (
<Button type="button" onClick={() => void onEnableClick()} loading={isSubmitting}>
<Trans>Enable AI features</Trans>
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,381 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import {
AiApiError,
type DetectFieldsProgressEvent,
detectFields,
} from '../../../server/api/ai/detect-fields.client';
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
type AiFieldDetectionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: (fields: NormalizedFieldWithContext[]) => void;
envelopeId: string;
teamId: number;
};
const PROCESSING_MESSAGES = [
msg`Reading your document`,
msg`Analyzing page layout`,
msg`Looking for form fields`,
msg`Detecting signature areas`,
msg`Identifying input fields`,
msg`Mapping fields to recipients`,
msg`Almost done`,
] as const;
const FIELD_TYPE_LABELS: Record<string, MessageDescriptor> = {
SIGNATURE: msg`Signature`,
INITIALS: msg`Initials`,
NAME: msg`Name`,
EMAIL: msg`Email`,
DATE: msg`Date`,
TEXT: msg`Text`,
NUMBER: msg`Number`,
CHECKBOX: msg`Checkbox`,
RADIO: msg`Radio`,
};
export const AiFieldDetectionDialog = ({
open,
onOpenChange,
onComplete,
envelopeId,
teamId,
}: AiFieldDetectionDialogProps) => {
const { _ } = useLingui();
const [state, setState] = useState<DialogState>('PROMPT');
const [messageIndex, setMessageIndex] = useState(0);
const [detectedFields, setDetectedFields] = useState<NormalizedFieldWithContext[]>([]);
const [error, setError] = useState<string | null>(null);
const [context, setContext] = useState('');
const [progress, setProgress] = useState<DetectFieldsProgressEvent | null>(null);
const onDetectClick = useCallback(async () => {
setState('PROCESSING');
setMessageIndex(0);
setError(null);
setProgress(null);
try {
await detectFields({
request: {
envelopeId,
teamId,
context: context || undefined,
},
onProgress: (progressEvent) => {
setProgress(progressEvent);
},
onComplete: (event) => {
setDetectedFields(event.fields);
setState('REVIEW');
},
onError: (err) => {
console.error('Detection failed:', err);
if (err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err.message);
setState('ERROR');
},
});
} catch (err) {
console.error('Detection failed:', err);
if (err instanceof AiApiError && err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err instanceof Error ? err.message : 'Failed to detect fields');
setState('ERROR');
}
}, [envelopeId, teamId, context]);
const onAddFields = () => {
onComplete(detectedFields);
onOpenChange(false);
setState('PROMPT');
setDetectedFields([]);
setContext('');
};
const onClose = () => {
onOpenChange(false);
setState('PROMPT');
setDetectedFields([]);
setError(null);
setContext('');
setProgress(null);
};
// Group fields by type for summary display
const fieldCountsByType = useMemo(() => {
const counts: Record<string, number> = {};
for (const field of detectedFields) {
counts[field.type] = (counts[field.type] || 0) + 1;
}
return Object.entries(counts).sort(([, a], [, b]) => b - a);
}, [detectedFields]);
useEffect(() => {
if (state !== 'PROCESSING') {
return;
}
const interval = setInterval(() => {
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
}, 4000);
return () => clearInterval(interval);
}, [state]);
return (
<Dialog open={open}>
<DialogContent className="sm:max-w-lg" hideClose={true}>
{state === 'PROMPT' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detect fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>
We'll scan your document to find form fields like signature lines, text inputs,
checkboxes, and more. Detected fields will be suggested for you to review.
</Trans>
</p>
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
<AlertDescription className="mt-0">
<Trans>
Your document is processed securely using AI services that don't retain your
data.
</Trans>
</AlertDescription>
</Alert>
<div className="space-y-1.5">
<Label htmlFor="context">
<Trans>Context</Trans>
</Label>
<Textarea
id="context"
placeholder={_(msg`David is the Employee, Lucas is the Manager`)}
value={context}
onChange={(e) => setContext(e.target.value)}
rows={2}
className="resize-none"
/>
<p className="text-xs text-muted-foreground">
<Trans>Help the AI assign fields to the right recipients.</Trans>
</p>
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Skip</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Detect</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'PROCESSING' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detecting fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center py-8">
<AnimatedDocumentScanner />
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
{progress && (
<p className="mt-2 text-xs text-muted-foreground/60">
<Plural
value={progress.fieldsDetected}
one={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # field found
</Trans>
}
other={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # fields found
</Trans>
}
/>
</p>
)}
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
<Trans>This can take a minute or two depending on the size of your document.</Trans>
</p>
<div className="mt-4 flex gap-1">
{PROCESSING_MESSAGES.map((_, index) => (
<div
key={index}
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
}`}
/>
))}
</div>
</div>
</>
)}
{state === 'REVIEW' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detected fields</Trans>
</DialogTitle>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto">
{detectedFields.length === 0 ? (
<div className="flex flex-col items-center py-8">
<FormInputIcon className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-center text-sm text-muted-foreground">
<Trans>No fields were detected in your document.</Trans>
</p>
<p className="mt-1 text-center text-xs text-muted-foreground/70">
<Trans>You can add fields manually in the editor.</Trans>
</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
<Plural
value={detectedFields.length}
one="We found # field in your document."
other="We found # fields in your document."
/>
</p>
<ul className="mt-4 divide-y rounded-lg border">
{fieldCountsByType.map(([type, count]) => (
<li key={type} className="flex items-center justify-between px-4 py-3">
<span className="text-sm">{_(FIELD_TYPE_LABELS[type]) || type}</span>
<span className="text-sm font-medium text-muted-foreground">{count}</span>
</li>
))}
</ul>
</>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Cancel</Trans>
</Button>
{detectedFields.length > 0 && (
<Button type="button" onClick={onAddFields}>
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Add fields</Trans>
</Button>
)}
</DialogFooter>
</>
)}
{state === 'ERROR' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detection failed</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>Something went wrong while detecting fields.</Trans>
</p>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'RATE_LIMITED' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Too many requests</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>
You've made too many detection requests. Please wait a minute before trying again.
</Trans>
</p>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -1,372 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
AiApiError,
type DetectRecipientsProgressEvent,
detectRecipients,
} from '../../../server/api/ai/detect-recipients.client';
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
type AiRecipientDetectionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onComplete: (recipients: TDetectedRecipientSchema[]) => void;
envelopeId: string;
teamId: number;
};
const PROCESSING_MESSAGES = [
msg`Reading your document`,
msg`Analyzing pages`,
msg`Looking for signature fields`,
msg`Identifying recipients`,
msg`Extracting contact details`,
msg`Almost done`,
] as const;
export const AiRecipientDetectionDialog = ({
open,
onOpenChange,
onComplete,
envelopeId,
teamId,
}: AiRecipientDetectionDialogProps) => {
const { _ } = useLingui();
const [state, setState] = useState<DialogState>('PROMPT');
const [messageIndex, setMessageIndex] = useState(0);
const [detectedRecipients, setDetectedRecipients] = useState<TDetectedRecipientSchema[]>([]);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<DetectRecipientsProgressEvent | null>(null);
const onDetectClick = useCallback(async () => {
setState('PROCESSING');
setMessageIndex(0);
setError(null);
setProgress(null);
try {
await detectRecipients({
request: {
envelopeId,
teamId,
},
onProgress: (progressEvent) => {
setProgress(progressEvent);
},
onComplete: (event) => {
setDetectedRecipients(event.recipients);
setState('REVIEW');
},
onError: (err) => {
console.error('Detection failed:', err);
if (err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err.message);
setState('ERROR');
},
});
} catch (err) {
console.error('Detection failed:', err);
if (err instanceof AiApiError && err.status === 429) {
setState('RATE_LIMITED');
return;
}
setError(err instanceof Error ? err.message : 'Failed to detect recipients');
setState('ERROR');
}
}, [envelopeId, teamId]);
const handleRemoveRecipient = (index: number) => {
setDetectedRecipients((prev) => prev.filter((_, i) => i !== index));
};
const onAddRecipients = () => {
onComplete(detectedRecipients);
onOpenChange(false);
setState('PROMPT');
setDetectedRecipients([]);
};
const onClose = () => {
onOpenChange(false);
setState('PROMPT');
setDetectedRecipients([]);
setError(null);
setProgress(null);
};
useEffect(() => {
if (state !== 'PROCESSING') {
return;
}
const interval = setInterval(() => {
setMessageIndex((prev) => (prev + 1) % PROCESSING_MESSAGES.length);
}, 4000);
return () => clearInterval(interval);
}, [state]);
return (
<Dialog open={open}>
<DialogContent className="sm:max-w-lg" hideClose={true}>
{state === 'PROMPT' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detect recipients</Trans>
</DialogTitle>
</DialogHeader>
<div>
<p className="text-sm text-muted-foreground">
<Trans>
We'll scan your document to find signature fields and identify who needs to sign.
Detected recipients will be suggested for you to review.
</Trans>
</p>
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
<AlertDescription className="mt-0">
<Trans>
Your document is processed securely using AI services that don't retain your
data.
</Trans>
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Skip</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Detect</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'PROCESSING' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detecting recipients</Trans>
</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center py-8">
<AnimatedDocumentScanner />
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
{progress && (
<p className="mt-2 text-xs text-muted-foreground/60">
<Plural
value={progress.recipientsDetected}
one={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # recipient found
</Trans>
}
other={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # recipients found
</Trans>
}
/>
</p>
)}
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
<Trans>This can take a minute or two depending on the size of your document.</Trans>
</p>
<div className="mt-4 flex gap-1">
{PROCESSING_MESSAGES.map((_, index) => (
<div
key={index}
className={`h-1.5 w-1.5 rounded-full transition-all duration-300 ${
index === messageIndex ? 'w-4 bg-primary' : 'bg-muted-foreground/30'
}`}
/>
))}
</div>
</div>
</>
)}
{state === 'REVIEW' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detected recipients</Trans>
</DialogTitle>
</DialogHeader>
<div className="max-h-[400px] overflow-y-auto">
{detectedRecipients.length === 0 ? (
<div className="flex flex-col items-center py-8">
<UserIcon className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-center text-sm text-muted-foreground">
<Trans>No recipients were detected in your document.</Trans>
</p>
<p className="mt-1 text-center text-xs text-muted-foreground/70">
<Trans>You can add recipients manually in the editor.</Trans>
</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
<Plural
value={detectedRecipients.length}
one="We found # recipient in your document."
other="We found # recipients in your document."
/>
</p>
<ul className="mt-4 divide-y rounded-lg border">
{detectedRecipients.map((recipient, index) => (
<li key={index} className="flex items-center justify-between px-4 py-3">
<AvatarWithText
avatarFallback={
recipient.name
? recipient.name.slice(0, 1).toUpperCase()
: recipient.email
? recipient.email.slice(0, 1).toUpperCase()
: '?'
}
primaryText={
<p className="text-sm font-medium text-foreground">
{recipient.name || _(msg`Unknown name`)}
</p>
}
secondaryText={
<div className="text-xs text-muted-foreground">
<p className="italic text-muted-foreground/70">
{recipient.email || _(msg`No email detected`)}
</p>
<p>{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
</div>
}
/>
<button
type="button"
className="h-8 w-8 p-0 text-muted-foreground/80 hover:text-destructive focus-visible:border-destructive focus-visible:ring-destructive"
onClick={() => handleRemoveRecipient(index)}
>
<span className="sr-only">
<Trans>Remove recipient</Trans>
</span>
<XIcon className="h-4 w-4" aria-hidden="true" />
</button>
</li>
))}
</ul>
</>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Cancel</Trans>
</Button>
{detectedRecipients.length > 0 && (
<Button type="button" onClick={onAddRecipients}>
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Add recipients</Trans>
</Button>
)}
</DialogFooter>
</>
)}
{state === 'ERROR' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Detection failed</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>Something went wrong while detecting recipients.</Trans>
</p>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
{state === 'RATE_LIMITED' && (
<>
<DialogHeader>
<DialogTitle>
<Trans>Too many requests</Trans>
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>
You've made too many detection requests. Please wait a minute before trying again.
</Trans>
</p>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="button" onClick={onDetectClick}>
<Trans>Try again</Trans>
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -162,7 +162,8 @@ export const EnvelopeDistributeDialog = ({
});
return (
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) &&
!recipient.email
);
});
}, [recipientsWithIndex, envelope.authOptions]);
@@ -475,7 +476,7 @@ export const EnvelopeDistributeDialog = ({
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
{recipient.email || recipient.name || `Recipient ${recipient.index + 1}`}
</li>
))}
</ul>
@@ -488,7 +489,7 @@ export const EnvelopeDistributeDialog = ({
<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}`}
{recipient.email || recipient.name || `Recipient ${recipient.index + 1}`}
</li>
))}
</ul>
@@ -24,10 +24,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
const ZSignFieldEmailFormSchema = z.object({
email: z
.string()
.email()
.min(1, { message: msg`Email is required`.id }),
email: z.string().min(1, { message: msg`Email is required`.id }),
});
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
@@ -103,8 +103,8 @@ export const TemplateBulkSendDialog = ({
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
title: 'Error',
description: 'Failed to upload CSV. Please check the file format and try again.',
variant: 'destructive',
});
}
@@ -101,29 +101,12 @@ export function TemplateUseDialog({
const [open, setOpen] = useState(false);
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
envelopeId,
},
{
placeholderData: (previousData) => previousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
enabled: open,
},
);
const envelopeItems = response?.data ?? [];
const generateDefaultFormValues = () => {
return {
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
distributeDocument: false,
useCustomDocument: false,
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
envelopeItemId: item.id,
})),
customDocumentData: [],
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
@@ -142,12 +125,7 @@ export function TemplateUseDialog({
signingOrder: recipient.signingOrder ?? undefined,
};
}),
};
};
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: generateDefaultFormValues(),
},
});
const { replace, fields: localCustomDocumentData } = useFieldArray({
@@ -155,6 +133,19 @@ export function TemplateUseDialog({
name: 'customDocumentData',
});
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
envelopeId,
},
{
placeholderData: (previousData) => previousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const envelopeItems = response?.data ?? [];
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
@@ -224,8 +215,8 @@ export function TemplateUseDialog({
});
useEffect(() => {
if (open) {
form.reset(generateDefaultFormValues());
if (!open) {
form.reset();
}
}, [open, form]);
@@ -332,7 +323,7 @@ export function TemplateUseDialog({
<Input
{...field}
aria-label="Name"
placeholder={recipients[index].name || _(msg`Recipient ${index + 1}`)}
placeholder={recipients[index].name || _(msg`Name`)}
/>
</FormControl>
<FormMessage />
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client';
@@ -230,8 +229,8 @@ export const ConfigureFieldsView = ({
setFieldClipboard(lastActiveField);
toast({
title: _(msg`Copied field`),
description: _(msg`Copied field to clipboard`),
title: 'Copied field',
description: 'Copied field to clipboard',
});
}
},
@@ -150,8 +150,8 @@ export const MultiSignDocumentSigningView = ({
onDocumentError?.();
toast({
title: _(msg`Error`),
description: _(msg`Failed to complete the document. Please try again.`),
title: 'Error',
description: 'Failed to complete the document. Please try again.',
variant: 'destructive',
});
} finally {
@@ -58,7 +58,6 @@ export type TDocumentPreferencesFormSchema = {
includeSigningCertificate: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
aiFeaturesEnabled: boolean | null;
};
type SettingsSubset = Pick<
@@ -73,13 +72,11 @@ type SettingsSubset = Pick<
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'aiFeaturesEnabled'
>;
export type DocumentPreferencesFormProps = {
settings: SettingsSubset;
canInherit: boolean;
isAiFeaturesConfigured?: boolean;
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
@@ -87,7 +84,6 @@ export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
canInherit,
isAiFeaturesConfigured = false,
}: DocumentPreferencesFormProps) => {
const { t } = useLingui();
const { user, organisations } = useSession();
@@ -109,7 +105,6 @@ export const DocumentPreferencesForm = ({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
aiFeaturesEnabled: z.boolean().nullable(),
});
const form = useForm<TDocumentPreferencesFormSchema>({
@@ -125,7 +120,6 @@ export const DocumentPreferencesForm = ({
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
aiFeaturesEnabled: settings.aiFeaturesEnabled,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
@@ -318,7 +312,7 @@ export const DocumentPreferencesForm = ({
}))}
selectedValues={field.value}
onChange={field.onChange}
className="w-full bg-background"
className="bg-background w-full"
enableSearch={false}
emptySelectionPlaceholder={
canInherit ? t`Inherit from organisation` : t`Select signature types`
@@ -384,7 +378,7 @@ export const DocumentPreferencesForm = ({
</FormControl>
<div className="pt-2">
<div className="text-xs font-medium text-muted-foreground">
<div className="text-muted-foreground text-xs font-medium">
<Trans>Preview</Trans>
</div>
@@ -515,59 +509,6 @@ export const DocumentPreferencesForm = ({
)}
/>
{isAiFeaturesConfigured && (
<FormField
control={form.control}
name="aiFeaturesEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>AI Features</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Enabled</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>Disabled</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Enable AI-powered features such as automatic recipient detection. When
enabled, document content will be sent to AI providers. We only use providers
that do not retain data for training and prefer European regions where
available.
</Trans>
</FormDescription>
</FormItem>
)}
/>
)}
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
+1 -1
View File
@@ -201,7 +201,7 @@ export const SignInForm = ({
.otherwise(() => handleFallbackErrorMessages(error.code));
toast({
title: _(msg`Something went wrong`),
title: 'Something went wrong',
description: _(errorMessage),
duration: 10000,
variant: 'destructive',
@@ -1,78 +0,0 @@
import { useEffect, useState } from 'react';
import { SearchIcon } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
export type AnimatedDocumentScannerProps = {
className?: string;
interval?: number;
};
export const AnimatedDocumentScanner = ({
className,
interval = 2500,
}: AnimatedDocumentScannerProps) => {
const [magPosition, setMagPosition] = useState({ x: 0, y: 0, page: 0 });
useEffect(() => {
const moveInterval = setInterval(() => {
setMagPosition({
x: Math.random() * 60 - 30,
y: Math.random() * 50 - 25,
page: Math.random() > 0.5 ? 1 : 0,
});
}, interval);
return () => clearInterval(moveInterval);
}, [interval]);
return (
<div className={cn('relative mx-auto h-36 w-56', className)}>
{/* Magnifying glass */}
<div
className="pointer-events-none absolute z-50 transition-all duration-1000 ease-in-out"
style={{
left: magPosition.page === 0 ? '25%' : '75%',
top: '50%',
transform: `translate(calc(-50% + ${magPosition.x}px), calc(-50% + ${magPosition.y}px))`,
}}
>
<SearchIcon className="h-8 w-8 text-foreground" />
</div>
{/* Book container */}
<div className="relative h-full w-full animate-pulse" style={{ perspective: '800px' }}>
<div className="relative h-full w-full" style={{ transformStyle: 'preserve-3d' }}>
{/* Left page */}
<div
className="absolute left-0 top-0 h-full w-[calc(50%)] origin-right overflow-hidden rounded-l-md border border-border bg-card shadow-md"
style={{ transform: 'rotateY(15deg) skewY(-1deg)' }}
>
<div className="absolute inset-3 space-y-2">
<div className="h-1.5 w-3/4 rounded-sm bg-muted" />
<div className="h-1.5 w-full rounded-sm bg-muted" />
<div className="h-1.5 w-5/6 rounded-sm bg-muted" />
<div className="h-1.5 w-2/3 rounded-sm bg-muted" />
<div className="mt-3 h-6 w-3/4 rounded border border-dashed border-primary" />
</div>
</div>
{/* Right page */}
<div
className="absolute right-0 top-0 h-full w-[calc(50%)] origin-left overflow-hidden rounded-r-md border border-border bg-card shadow-md"
style={{ transform: 'rotateY(-15deg) skewY(1deg)' }}
>
<div className="absolute inset-3 space-y-2">
<div className="h-1.5 w-full rounded-sm bg-muted" />
<div className="h-1.5 w-4/5 rounded-sm bg-muted" />
<div className="h-1.5 w-full rounded-sm bg-muted" />
<div className="h-1.5 w-3/5 rounded-sm bg-muted" />
<div className="mt-3 h-6 w-2/3 rounded border border-dashed border-primary" />
</div>
</div>
</div>
</div>
</div>
);
};
@@ -56,13 +56,13 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
/>
<div
className="text-sm text-muted-foreground"
className="text-muted-foreground text-sm"
title={
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
}
>
<p>{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
<p>{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -63,7 +63,6 @@ export type DocumentSigningCompleteDialogProps = {
};
buttonSize?: 'sm' | 'lg';
position?: 'start' | 'end' | 'center';
disableNameInput?: boolean;
};
const ZNextSignerFormSchema = z.object({
@@ -94,7 +93,6 @@ export const DocumentSigningCompleteDialog = ({
defaultNextSigner,
buttonSize = 'lg',
position,
disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => {
const { t } = useLingui();
@@ -276,7 +274,7 @@ export const DocumentSigningCompleteDialog = ({
{...field}
className="mt-2"
placeholder={t`Enter your name`}
disabled={isNameLocked || disableNameInput}
disabled={isNameLocked}
/>
</FormControl>
@@ -108,8 +108,8 @@ export const DocumentSigningForm = ({
await completeDocument({ nextSigner });
} catch (err) {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while completing the document. Please try again.`),
title: 'Error',
description: 'An error occurred while completing the document. Please try again.',
variant: 'destructive',
});
@@ -187,74 +187,45 @@ export const DocumentSigningPageViewV1 = ({
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-y-2 sm:mt-2.5 sm:gap-y-0">
<div className="max-w-[50ch]">
<span className="truncate text-muted-foreground" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
<span className="text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to view this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to view this document
</Trans>
<Trans>has invited you to view this document</Trans>
),
)
.with(RecipientRole.SIGNER, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to sign this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to sign this document
</Trans>
<Trans>has invited you to sign this document</Trans>
),
)
.with(RecipientRole.APPROVER, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to approve this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to approve this document
</Trans>
<Trans>has invited you to approve this document</Trans>
),
)
.with(RecipientRole.ASSISTANT, () =>
includeSenderDetails ? (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
on behalf of "{document.team?.name}" has invited you to assist this document
</Trans>
) : (
<Trans>
<span className="truncate" title={senderName}>
{senderName} {senderEmail}
</span>{' '}
has invited you to assist this document
</Trans>
<Trans>has invited you to assist this document</Trans>
),
)
.otherwise(() => null)}
@@ -74,8 +74,8 @@ export function DocumentSigningRejectDialog({
});
toast({
title: t`Document rejected`,
description: t`The document has been successfully rejected.`,
title: 'Document rejected',
description: 'The document has been successfully rejected.',
duration: 5000,
});
@@ -88,8 +88,8 @@ export function DocumentSigningRejectDialog({
}
} catch (err) {
toast({
title: t`Error`,
description: t`An error occurred while rejecting the document. Please try again.`,
title: 'Error',
description: 'An error occurred while rejecting the document. Please try again.',
variant: 'destructive',
duration: 5000,
});
@@ -1,31 +1,28 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { lazy, useEffect, useMemo } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { Link, useRevalidator, useSearchParams } from 'react-router';
import { Trans, useLingui } from '@lingui/react/macro';
import { FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import {
FIELD_META_DEFAULT_VALUES,
type TCheckboxFieldMeta,
type TDateFieldMeta,
type TDropdownFieldMeta,
type TEmailFieldMeta,
type TFieldMetaSchema,
type TInitialsFieldMeta,
type TNameFieldMeta,
type TNumberFieldMeta,
type TRadioFieldMeta,
type TSignatureFieldMeta,
type TTextFieldMeta,
import type {
TCheckboxFieldMeta,
TDateFieldMeta,
TDropdownFieldMeta,
TEmailFieldMeta,
TFieldMetaSchema,
TInitialsFieldMeta,
TNameFieldMeta,
TNumberFieldMeta,
TRadioFieldMeta,
TSignatureFieldMeta,
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';
@@ -34,8 +31,6 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
@@ -46,7 +41,6 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
@@ -73,17 +67,11 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
export const EnvelopeEditorFieldsPage = () => {
const [searchParams] = useSearchParams();
const team = useCurrentTeam();
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { _ } = useLingui();
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
const { t } = useLingui();
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
@@ -108,24 +96,6 @@ 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.
*/
@@ -138,22 +108,6 @@ export const EnvelopeEditorFieldsPage = () => {
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
}, []);
const onDetectClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
}
setIsAiFieldDialogOpen(true);
};
const onAiFeaturesEnabled = () => {
void revalidate().then(() => {
setIsAiEnableDialogOpen(false);
setIsAiFieldDialogOpen(true);
});
};
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
@@ -248,37 +202,6 @@ export const EnvelopeEditorFieldsPage = () => {
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</section>
{/* Field details section. */}
@@ -320,7 +243,7 @@ export const EnvelopeEditorFieldsPage = () => {
<div className="px-4 [&_label]:text-xs [&_label]:text-foreground/70">
<h3 className="text-sm font-semibold">
{_(FieldSettingsTypeTranslations[selectedField.type])}
{t(FieldSettingsTypeTranslations[selectedField.type])}
</h3>
{match(selectedField.type)
@@ -8,13 +8,11 @@ import {
type SensorAPI,
} from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual, prop, sortBy } from 'remeda';
import { z } from 'zod';
@@ -23,7 +21,6 @@ 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,
@@ -63,10 +60,6 @@ import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
const ZEnvelopeRecipientsForm = z.object({
signers: z.array(
z.object({
@@ -89,59 +82,13 @@ export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope } = useCurrentEnvelopeEditor();
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { t } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { user } = useSession();
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
// AI recipient detection dialog state
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
const { revalidate } = useRevalidator();
const onAiDialogOpenChange = (open: boolean) => {
if (open && !team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
setIsAiDialogOpen(false);
return;
}
setIsAiDialogOpen(open);
if (!open && searchParams.get('ai') === 'true') {
setSearchParams(
(prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete('ai');
return newParams;
},
{ replace: true },
);
}
};
const onDetectRecipientsClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
}
setIsAiDialogOpen(true);
};
const onAiFeaturesEnabled = () => {
void revalidate().then(() => {
setIsAiEnableDialogOpen(false);
setIsAiDialogOpen(true);
});
};
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
@@ -295,77 +242,6 @@ export const EnvelopeEditorRecipientForm = () => {
});
};
const onAiDetectionComplete = (detectedRecipients: TDetectedRecipientSchema[]) => {
const currentSigners = form.getValues('signers');
let nextSigningOrder =
currentSigners.length > 0
? Math.max(...currentSigners.map((s) => s.signingOrder ?? 0)) + 1
: 1;
// If the only signer is the default empty signer lets just replace it with the detected recipients
if (currentSigners.length === 1 && !currentSigners[0].name && !currentSigners[0].email) {
form.setValue(
'signers',
detectedRecipients.map((recipient, index) => ({
formId: nanoid(12),
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth: [],
signingOrder: index + 1,
})),
{
shouldValidate: true,
shouldDirty: true,
},
);
return;
}
for (const recipient of detectedRecipients) {
const emailExists = currentSigners.some(
(s) => s.email.toLowerCase() === recipient.email.toLowerCase(),
);
const nameExists = currentSigners.some(
(s) => s.name.toLowerCase() === recipient.name.toLowerCase(),
);
if ((emailExists && recipient.email) || (nameExists && recipient.name)) {
continue;
}
currentSigners.push({
formId: nanoid(12),
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth: [],
signingOrder: nextSigningOrder,
});
nextSigningOrder += 1;
}
form.setValue('signers', normalizeSigningOrders(currentSigners), {
shouldValidate: true,
shouldDirty: true,
});
toast({
title: plural(detectedRecipients.length, {
one: `Recipient added`,
other: `Recipients added`,
}),
description: plural(detectedRecipients.length, {
one: `# recipient have been added from AI detection.`,
other: `# recipients have been added from AI detection.`,
}),
});
};
const onRemoveSigner = (index: number) => {
const signer = signers[index];
@@ -428,14 +304,8 @@ export const EnvelopeEditorRecipientForm = () => {
index: number,
suggestion: RecipientAutoCompleteOption,
) => {
setValue(`signers.${index}.email`, suggestion.email, {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.name`, suggestion.name || '', {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${index}.email`, suggestion.email);
setValue(`signers.${index}.name`, suggestion.name || '');
};
const onDragEnd = useCallback(
@@ -657,28 +527,6 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<div className="flex flex-row items-center space-x-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
<Button
variant="outline"
className="flex flex-row items-center"
@@ -850,7 +698,7 @@ export const EnvelopeEditorRecipientForm = () => {
>
{signers.map((signer, index) => (
<Draggable
key={`${signer.nativeId}-${signer.signingOrder}`}
key={`${signer.id}-${signer.signingOrder}`}
draggableId={signer['nativeId']}
index={index}
isDragDisabled={
@@ -994,7 +842,7 @@ export const EnvelopeEditorRecipientForm = () => {
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Recipient ${index + 1}`}
placeholder={t`Name`}
{...field}
disabled={
snapshot.isDragging ||
@@ -1126,20 +974,6 @@ export const EnvelopeEditorRecipientForm = () => {
onOpenChange={setShowSigningOrderConfirmation}
onConfirm={handleSigningOrderDisable}
/>
<AiRecipientDetectionDialog
open={isAiDialogOpen}
onOpenChange={onAiDialogOpenChange}
onComplete={onAiDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</CardContent>
</Card>
);
@@ -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, plural } from '@lingui/core/macro';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { FileWarningIcon, GripVerticalIcon, Loader2 } from 'lucide-react';
@@ -226,12 +226,7 @@ export const EnvelopeEditorUploadPage = () => {
}
if (maximumEnvelopeItemCount <= localFiles.length) {
return msg({
message: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
});
return msg`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`;
}
return null;
@@ -245,10 +240,7 @@ export const EnvelopeEditorUploadPage = () => {
if (maxItemsReached) {
toast({
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
@@ -1,7 +1,5 @@
import { useCallback, useState } from 'react';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
@@ -41,12 +39,10 @@ export const EnvelopeRecipientSelector = ({
fields,
align = 'start',
}: EnvelopeRecipientSelectorProps) => {
const { i18n } = useLingui();
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
(recipient: Recipient) => extractRecipientLabel(recipient, recipients),
[recipients],
);
@@ -110,7 +106,7 @@ export const EnvelopeRecipientSelectorCommand = ({
fields,
placeholder,
}: EnvelopeRecipientSelectorCommandProps) => {
const { t, i18n } = useLingui();
const { t } = useLingui();
const recipientsByRole = useCallback(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
@@ -160,7 +156,7 @@ export const EnvelopeRecipientSelectorCommand = ({
);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
(recipient: Recipient) => extractRecipientLabel(recipient, recipients),
[recipients],
);
@@ -255,7 +251,7 @@ export const EnvelopeRecipientSelectorCommand = ({
);
};
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[]) => {
if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`;
}
@@ -271,5 +267,5 @@ const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i1
// Since objects are basically pointers we can use `indexOf` rather than `findIndex`
const index = recipients.indexOf(recipient);
return i18n._(msg`Recipient ${index + 1}`);
return `Recipient ${index + 1}`;
};
@@ -225,7 +225,7 @@ export const EnvelopeSignerCompleteDialog = () => {
name: fullName,
email: email,
};
}, [email, fullName, isDirectTemplate, recipient.email, recipient.name, recipient.fields]);
}, [email, fullName, isDirectTemplate]);
return (
<DocumentSigningCompleteDialog
@@ -241,7 +241,6 @@ export const EnvelopeSignerCompleteDialog = () => {
allowDictateNextSigner={Boolean(
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
)}
disableNameInput={!isDirectTemplate && recipient.name !== ''}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
@@ -1,6 +1,5 @@
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';
@@ -116,9 +115,7 @@ export const EnvelopeDropZoneWrapper = ({
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
await navigate(`${pathPrefix}/${id}/edit`);
} catch (err) {
const error = AppError.parseError(err);
@@ -156,10 +153,7 @@ export const EnvelopeDropZoneWrapper = ({
if (maxItemsReached) {
toast({
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
@@ -226,9 +220,9 @@ export const EnvelopeDropZoneWrapper = ({
{children}
{isDragActive && (
<div className="fixed left-0 top-0 z-[9999] h-full w-full bg-muted/60 backdrop-blur-[4px]">
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-2xl font-semibold text-foreground">
<h2 className="text-foreground text-2xl font-semibold">
{type === EnvelopeType.DOCUMENT ? (
<Trans>Upload Document</Trans>
) : (
@@ -236,7 +230,7 @@ export const EnvelopeDropZoneWrapper = ({
)}
</h2>
<p className="text-md mt-4 text-muted-foreground">
<p className="text-muted-foreground text-md mt-4">
<Trans>Drag and drop your PDF file here</Trans>
</p>
@@ -253,7 +247,7 @@ export const EnvelopeDropZoneWrapper = ({
team?.id === undefined &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<p className="mt-4 text-sm text-muted-foreground/80">
<p className="text-muted-foreground/80 mt-4 text-sm">
<Trans>
{remaining.documents} of {quota.documents} documents remaining this month.
</Trans>
@@ -264,10 +258,10 @@ export const EnvelopeDropZoneWrapper = ({
)}
{isLoading && (
<div className="absolute inset-0 z-50 bg-muted/30 backdrop-blur-[2px]">
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="h-12 w-12 animate-spin text-primary" />
<p className="mt-8 font-medium text-foreground">
<Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium">
<Trans>Uploading</Trans>
</p>
</div>
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { msg, plural } from '@lingui/core/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
@@ -108,9 +108,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
const aiQueryParam = team.preferences.aiFeaturesEnabled ? '?ai=true' : '';
await navigate(`${pathPrefix}/${id}/edit${aiQueryParam}`);
await navigate(`${pathPrefix}/${id}/edit`);
toast({
title: type === EnvelopeType.DOCUMENT ? t`Document uploaded` : t`Template uploaded`,
@@ -155,10 +153,7 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
if (maxItemsReached) {
toast({
title: plural(maximumEnvelopeItemCount, {
one: `You cannot upload more than # item per envelope.`,
other: `You cannot upload more than # items per envelope.`,
}),
title: t`You cannot upload more than ${maximumEnvelopeItemCount} items per envelope.`,
duration: 5000,
variant: 'destructive',
});
@@ -83,8 +83,8 @@ export const StackAvatarsWithTooltip = ({
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -107,8 +107,8 @@ export const StackAvatarsWithTooltip = ({
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
-2
View File
@@ -10,8 +10,6 @@ import { HydratedRouter } from 'react-router/dom';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import './utils/polyfills/promise-with-resolvers';
function PosthogInit() {
const postHogConfig = extractPostHogConfig();
+2 -16
View File
@@ -46,16 +46,12 @@ export async function loader({ request }: Route.LoaderArgs) {
const { getTheme } = await themeSessionResolver(request);
const cookieHeader = request.headers.get('cookie') ?? '';
let lang: SupportedLanguageCodes = await langCookie.parse(cookieHeader);
let lang: SupportedLanguageCodes = await langCookie.parse(request.headers.get('cookie') ?? '');
if (!APP_I18N_OPTIONS.supportedLangs.includes(lang)) {
lang = extractLocaleData({ headers: request.headers }).lang;
}
const disableAnimations = cookieHeader.includes('__disable_animations=true');
let organisations = null;
if (session.isAuthenticated) {
@@ -66,7 +62,6 @@ export async function loader({ request }: Route.LoaderArgs) {
{
lang,
theme: getTheme(),
disableAnimations,
session: session.isAuthenticated
? {
user: session.user,
@@ -97,8 +92,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
}
export function LayoutContent({ children }: { children: React.ReactNode }) {
const { publicEnv, session, lang, disableAnimations, ...data } =
useLoaderData<typeof loader>() || {};
const { publicEnv, session, lang, ...data } = useLoaderData<typeof loader>() || {};
const [theme] = useTheme();
@@ -117,14 +111,6 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<meta name="google" content="notranslate" />
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
{disableAnimations && (
<style
dangerouslySetInnerHTML={{
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
}}
/>
)}
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
<script>0</script>
</head>
@@ -1,7 +1,7 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
@@ -86,7 +86,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
)}
</div>
<div className="mt-4 text-sm text-muted-foreground">
<div className="text-muted-foreground mt-4 text-sm">
<div>
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
</div>
@@ -112,8 +112,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
disabled={envelope.recipients.some(
(recipient) =>
recipient.signingStatus !== SigningStatus.SIGNED &&
recipient.signingStatus !== SigningStatus.REJECTED &&
recipient.role !== RecipientRole.CC,
recipient.signingStatus !== SigningStatus.REJECTED,
)}
onClick={() => resealDocument({ id: envelope.id })}
>
@@ -1,10 +1,8 @@
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
@@ -21,16 +19,9 @@ export function meta() {
return appMetaTags('Document Preferences');
}
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function OrganisationSettingsDocumentPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
const { organisations } = useSession();
const organisation = useCurrentOrganisation();
const { t } = useLingui();
@@ -57,7 +48,6 @@ export default function OrganisationSettingsDocumentPage() {
includeSigningCertificate,
includeAuditLog,
signatureTypes,
aiFeaturesEnabled,
} = data;
if (
@@ -66,8 +56,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat === null ||
includeSenderDetails === null ||
includeSigningCertificate === null ||
includeAuditLog === null ||
aiFeaturesEnabled === null
includeAuditLog === null
) {
throw new Error('Should not be possible.');
}
@@ -85,7 +74,6 @@ export default function OrganisationSettingsDocumentPage() {
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
aiFeaturesEnabled,
},
});
@@ -105,7 +93,7 @@ export default function OrganisationSettingsDocumentPage() {
if (isLoadingOrganisation || !organisationWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
@@ -122,7 +110,6 @@ export default function OrganisationSettingsDocumentPage() {
<section>
<DocumentPreferencesForm
canInherit={false}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={organisationWithSettings.organisationGlobalSettings}
onFormSubmit={onDocumentPreferencesFormSubmit}
/>
@@ -1,5 +1,5 @@
import DocumentPage, { loader, meta } from '../../o.$orgUrl.settings.document';
import DocumentPage, { meta } from '../../o.$orgUrl.settings.document';
export { meta, loader };
export { meta };
export default DocumentPage;
@@ -1,8 +1,6 @@
import { useLingui } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useLoaderData } from 'react-router';
import { IS_AI_FEATURES_CONFIGURED } from '@documenso/lib/constants/app';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -19,15 +17,7 @@ export function meta() {
return appMetaTags('Document Preferences');
}
export const loader = () => {
return {
isAiFeaturesConfigured: IS_AI_FEATURES_CONFIGURED(),
};
};
export default function TeamsSettingsPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
const team = useCurrentTeam();
const { t } = useLingui();
@@ -50,7 +40,6 @@ export default function TeamsSettingsPage() {
includeSigningCertificate,
includeAuditLog,
signatureTypes,
aiFeaturesEnabled,
} = data;
await updateTeamSettings({
@@ -63,7 +52,6 @@ export default function TeamsSettingsPage() {
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
aiFeaturesEnabled,
...(signatureTypes.length === 0
? {
typedSignatureEnabled: null,
@@ -94,7 +82,7 @@ export default function TeamsSettingsPage() {
if (isLoadingTeam || !teamWithSettings) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}
@@ -109,7 +97,6 @@ export default function TeamsSettingsPage() {
<section>
<DocumentPreferencesForm
canInherit={true}
isAiFeaturesConfigured={isAiFeaturesConfigured}
settings={teamWithSettings.teamSettings}
onFormSubmit={onDocumentPreferencesSubmit}
/>
@@ -1,9 +1,9 @@
import satori from 'satori';
import sharp from 'sharp';
import { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/document/get-recipient-or-sender-by-share-link-slug';
import { svgToPng } from '@documenso/lib/utils/images/svg-to-png';
import type { Route } from './+types/share.$slug.opengraph';
@@ -181,7 +181,8 @@ export const loader = async ({ params }: Route.LoaderArgs) => {
},
);
const pngBuffer = await svgToPng(svg.toString());
// Convert SVG to PNG using sharp
const pngBuffer = await sharp(Buffer.from(svg)).toFormat('png').toBuffer();
return new Response(pngBuffer, {
headers: {
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AlertTriangle, Building2, Database, Eye, Settings, UserCircle2 } from 'lucide-react';
import { data, isRouteErrorResponse } from 'react-router';
@@ -126,7 +125,6 @@ export async function loader({ params }: Route.LoaderArgs) {
export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Route.ComponentProps) {
const { token, type, user, organisation } = loaderData;
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
@@ -138,12 +136,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
await navigate('/');
toast({
title: _(msg`Account link declined`),
title: 'Account link declined',
});
},
onError: (error) => {
toast({
title: _(msg`Error declining account link`),
title: 'Error declining account link',
description: error.message,
});
},
@@ -155,12 +153,12 @@ export default function OrganisationSsoConfirmationTokenPage({ loaderData }: Rou
await navigate(formatOrganisationLoginPath(organisation.url));
toast({
title: _(msg`Account linked successfully`),
title: 'Account linked successfully',
});
},
onError: (error) => {
toast({
title: _(msg`Error linking account`),
title: 'Error linking account',
description: error.message,
});
},
@@ -1,5 +1,6 @@
import sharp from 'sharp';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { loadLogo } from '@documenso/lib/utils/images/logo';
import { prisma } from '@documenso/prisma';
import type { Route } from './+types/branding.logo.organisation.$orgId';
@@ -62,12 +63,16 @@ export async function loader({ params }: Route.LoaderArgs) {
);
}
const { content, contentType } = await loadLogo(file);
const img = await sharp(file)
.toFormat('png', {
quality: 80,
})
.toBuffer();
return new Response(content, {
return new Response(Buffer.from(img), {
headers: {
'Content-Type': contentType,
'Content-Length': content.length.toString(),
'Content-Type': 'image/png',
'Content-Length': img.length.toString(),
// Stale while revalidate for 1 hours to 24 hours
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
@@ -1,6 +1,7 @@
import sharp from 'sharp';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { loadLogo } from '@documenso/lib/utils/images/logo';
import type { Route } from './+types/branding.logo.team.$teamId';
@@ -55,12 +56,16 @@ export async function loader({ params }: Route.LoaderArgs) {
);
}
const { content, contentType } = await loadLogo(file);
const img = await sharp(file)
.toFormat('png', {
quality: 80,
})
.toBuffer();
return new Response(content, {
return new Response(img, {
headers: {
'Content-Type': contentType,
'Content-Length': content.length.toString(),
'Content-Type': 'image/png',
'Content-Length': img.length.toString(),
// Stale while revalidate for 1 hours to 24 hours
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
@@ -41,9 +41,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
const token = url.searchParams.get('token') || '';
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token, scope: `documentId:${id}` }).catch(
() => null,
);
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Error('Invalid token');
@@ -41,9 +41,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
const token = url.searchParams.get('token') || '';
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token, scope: `templateId:${id}` }).catch(
() => null,
);
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
if (!result) {
throw new Error('Invalid token');
@@ -1,30 +0,0 @@
/**
* 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 {};
+2 -1
View File
@@ -70,6 +70,7 @@
"remeda": "^2.32.0",
"remix-themes": "^2.0.4",
"satori": "^0.18.3",
"sharp": "0.34.5",
"tailwindcss": "^3.4.18",
"ts-pattern": "^5.9.0",
"ua-parser-js": "^1.0.41",
@@ -107,5 +108,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.2.6"
"version": "2.1.0"
}
@@ -1,161 +0,0 @@
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();
}
};
-154
View File
@@ -1,154 +0,0 @@
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);
}
},
);
@@ -1,54 +0,0 @@
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>;
@@ -1,160 +0,0 @@
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();
}
};
@@ -1,152 +0,0 @@
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);
}
},
);
@@ -1,16 +0,0 @@
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>;
-9
View File
@@ -1,9 +0,0 @@
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);
+1 -24
View File
@@ -12,11 +12,9 @@ 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';
@@ -52,21 +50,6 @@ 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.
*/
@@ -102,10 +85,6 @@ 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);
@@ -136,8 +115,6 @@ app.use(`${API_V2_BETA_URL}/*`, async (c) =>
// Start telemetry client for anonymous usage tracking.
// Can be disabled by setting DOCUMENSO_DISABLE_TELEMETRY=true
if (env('NODE_ENV') !== 'development') {
void TelemetryClient.start();
}
void TelemetryClient.start();
export default app;
-3
View File
@@ -51,7 +51,6 @@ export default defineConfig({
ssr: {
noExternal: ['react-dropzone', 'plausible-tracker'],
external: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@prisma/client',
'@documenso/tailwind-config',
@@ -65,7 +64,6 @@ export default defineConfig({
include: ['prop-types', 'file-selector', 'attr-accept'],
exclude: [
'node_modules',
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@documenso/pdf-sign',
'sharp',
@@ -96,7 +94,6 @@ export default defineConfig({
build: {
rollupOptions: {
external: [
'@napi-rs/canvas',
'@node-rs/bcrypt',
'@documenso/pdf-sign',
'@aws-sdk/cloudfront-signer',
-3
View File
@@ -70,7 +70,6 @@ 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
@@ -109,8 +108,6 @@ 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
+758 -921
View File
File diff suppressed because it is too large Load Diff
+3 -7
View File
@@ -5,9 +5,8 @@
"apps/*",
"packages/*"
],
"version": "2.2.6",
"version": "2.1.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
"dev:remix": "turbo run dev --filter=@documenso/remix",
@@ -64,7 +63,7 @@
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^7.0.10",
"pdfjs-dist": "5.4.449",
"pdfjs-dist": "5.4.296",
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
@@ -84,22 +83,19 @@
"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.449",
"pdfjs-dist": "5.4.296",
"typescript": "5.6.2",
"zod": "^3.25.76"
}
@@ -1,9 +1,6 @@
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';
@@ -20,7 +17,18 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
const response = await createPresignToken(request, token);
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 responseData = await response.json();
@@ -46,9 +54,19 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
const response = await createPresignToken(request, token, {
expiresIn: 120, // 2 hours
});
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 responseData = await response.json();
@@ -74,9 +92,19 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
const response = await createPresignToken(request, token, {
expiresIn: 0, // Immediate expiration
});
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
},
},
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
@@ -101,7 +129,18 @@ test.describe('Embedding Presign API', () => {
});
// First create a token
const createResponse = await createPresignToken(request, 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,
},
},
);
expect(createResponse.ok()).toBeTruthy();
const createResponseData = await createResponse.json();
@@ -111,9 +150,18 @@ test.describe('Embedding Presign API', () => {
const presignToken = createResponseData.token;
// Then verify it
const verifyResponse = await verifyPresignToken(request, token, {
token: presignToken,
});
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,
},
},
);
expect(verifyResponse.ok()).toBeTruthy();
expect(verifyResponse.status()).toBe(200);
@@ -135,87 +183,18 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
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 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 responseData = await response.json();
@@ -227,40 +206,3 @@ 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,
},
);
};
@@ -147,9 +147,6 @@ 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',
},
@@ -268,6 +265,8 @@ 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');
@@ -3103,7 +3103,7 @@ test.describe('Document API V2', () => {
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope use endpoint', async ({ request }) => {
test('should allow authorized access to envelope use endpoint', async ({ page, request }) => {
const doc = await seedTemplate({
title: 'Team template 1',
userId: userA.id,
@@ -4313,62 +4313,5 @@ 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,8 +89,9 @@ test.describe('AutoSave Fields Step', () => {
});
expect(retrievedFields.length).toBe(3);
expect(retrievedFields.filter((field) => field.type === 'SIGNATURE')).toHaveLength(2);
expect(retrievedFields.filter((field) => field.type === 'TEXT')).toHaveLength(1);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
}).toPass();
});
@@ -213,8 +214,10 @@ test.describe('AutoSave Fields Step', () => {
});
expect(retrievedFields.length).toBe(4);
expect(retrievedFields.filter((field) => field.type === 'SIGNATURE')).toHaveLength(3);
expect(retrievedFields.filter((field) => field.type === 'TEXT')).toHaveLength(1);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
expect(retrievedFields[3].type).toBe('SIGNATURE');
}).toPass();
});
@@ -256,16 +259,10 @@ test.describe('AutoSave Fields Step', () => {
});
expect(retrievedFields.length).toBe(2);
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');
}
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
const textField = retrievedFields[1];
expect(textField.fieldMeta).toBeDefined();
if (
@@ -9,7 +9,6 @@ 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' });
@@ -84,13 +83,14 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
});
// Open document action menu.
const documentActionBtn = page
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(200);
// 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,13 +128,14 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
});
// Open document action menu.
const documentActionBtn = page
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(200);
// 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();
@@ -168,17 +169,19 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
});
// Open document action menu.
const documentActionBtn = page
await page
.locator('tr', { hasText: 'Document 1 - Draft' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
.getByTestId('document-table-action-btn')
.click();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible(); // Required to reduce flakiness.
await page.waitForTimeout(200);
// delete document
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 expectToastTextToBeVisible(page, 'Document deleted');
await page.waitForTimeout(2500);
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
@@ -200,13 +203,14 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
});
// Open document action menu.
const documentActionBtn = page
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(200);
// 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();
@@ -235,13 +239,14 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
});
// Open document action menu.
const documentActionBtn = page
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(200);
// 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();
@@ -287,24 +292,36 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
});
// Open document action menu.
const completedDocActionBtn = page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, completedDocActionBtn);
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();
// Delete document.
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
await page.waitForTimeout(2000);
const pendingDocActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, pendingDocActionBtn);
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();
// Delete document.
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test';
import { type Page } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
@@ -58,15 +58,3 @@ const getCsrfToken = async (page: Page) => {
return csrfToken;
};
export const checkSessionValid = async (page: Page): Promise<boolean> => {
const { request } = page.context();
const response = await request.fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/session`, {
method: 'get',
});
const session = await response.json();
return session.isAuthenticated === true;
};
@@ -1,4 +1,3 @@
import type { Locator } from '@playwright/test';
import { type Page, expect } from '@playwright/test';
export const expectTextToBeVisible = async (page: Page, text: string) => {
@@ -8,22 +7,3 @@ 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, openDropdownMenu } from '../fixtures/generic';
import { expectTextToBeVisible } from '../fixtures/generic';
test.describe.configure({ mode: 'parallel' });
@@ -117,9 +117,7 @@ test('[TEAMS]: can pin a document folder', async ({ page }) => {
redirectPath: `/t/${team.url}/documents`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
await page.getByTestId('folder-card-more-button').click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await page.reload();
@@ -144,9 +142,7 @@ test('[TEAMS]: can unpin a document folder', async ({ page }) => {
redirectPath: `/t/${team.url}/documents`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Unpin' })).toBeVisible();
await page.getByTestId('folder-card-more-button').click();
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await page.reload();
@@ -170,9 +166,7 @@ test('[TEAMS]: can rename a document folder', async ({ page }) => {
redirectPath: `/t/${team.url}/documents`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByTestId('folder-card-more-button').click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await page.getByLabel('Name').fill('Team Archive');
@@ -197,9 +191,7 @@ test('[TEAMS]: document folder visibility is visible to team member', async ({ p
redirectPath: `/t/${team.url}/documents`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByTestId('folder-card-more-button').click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('combobox', { name: 'Visibility' })).toBeVisible();
@@ -228,9 +220,7 @@ test('[TEAMS]: document folder can be moved to another document folder', async (
redirectPath: `/t/${team.url}/documents`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button').nth(0);
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
await page.getByTestId('folder-card-more-button').nth(0).click();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Team Clients' }).click();
@@ -281,9 +271,7 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
redirectPath: `/t/${team.url}/documents`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByTestId('folder-card-more-button').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('textbox').fill(`delete ${folder.name}`);
@@ -292,8 +280,12 @@ 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)).toBeVisible();
await expect(page.getByText(report.title)).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();
});
test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => {
@@ -418,9 +410,7 @@ test('[TEAMS]: can pin a template folder', async ({ page }) => {
redirectPath: `/t/${team.url}/templates`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
await page.getByTestId('folder-card-more-button').click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await page.reload();
@@ -446,9 +436,7 @@ test('[TEAMS]: can unpin a template folder', async ({ page }) => {
redirectPath: `/t/${team.url}/templates`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Unpin' })).toBeVisible();
await page.getByTestId('folder-card-more-button').click();
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await page.reload();
@@ -474,9 +462,7 @@ test('[TEAMS]: can rename a template folder', async ({ page }) => {
redirectPath: `/t/${team.url}/templates`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByTestId('folder-card-more-button').click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await page.getByLabel('Name').fill('Updated Team Template Folder');
@@ -502,9 +488,7 @@ test('[TEAMS]: template folder visibility is not visible to team member', async
redirectPath: `/t/${team.url}/templates`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByTestId('folder-card-more-button').click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
@@ -535,9 +519,7 @@ test('[TEAMS]: template folder can be moved to another template folder', async (
redirectPath: `/t/${team.url}/templates`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button').nth(0);
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
await page.getByTestId('folder-card-more-button').nth(0).click();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Team Client Templates' }).click();
@@ -590,9 +572,7 @@ test('[TEAMS]: template folder can be deleted', async ({ page }) => {
redirectPath: `/t/${team.url}/templates`,
});
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByTestId('folder-card-more-button').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('textbox').fill(`delete ${folder.name}`);
@@ -781,9 +761,7 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
await page.goto(`/t/${team.url}/documents/`);
const folderMoreBtn1 = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn1);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByTestId('folder-card-more-button').click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Admins only');
@@ -803,9 +781,7 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
await page.goto(`/t/${team.url}/documents`);
const folderMoreBtn2 = page.getByTestId('folder-card-more-button').nth(0);
await openDropdownMenu(page, folderMoreBtn2);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByTestId('folder-card-more-button').nth(0).click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Managers and above');
@@ -825,9 +801,7 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
await page.goto(`/t/${team.url}/documents/`);
const folderMoreBtn3 = page.getByTestId('folder-card-more-button').nth(0);
await openDropdownMenu(page, folderMoreBtn3);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByTestId('folder-card-more-button').nth(0).click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Everyone');
@@ -992,9 +966,7 @@ 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/ });
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
await everyoneDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1046,9 +1018,7 @@ 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/ });
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await managerDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1101,9 +1071,7 @@ 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/ });
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await managerDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1156,9 +1124,7 @@ 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/ });
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await everyoneDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1211,9 +1177,7 @@ 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/ });
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await adminDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -1264,9 +1228,7 @@ 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/ });
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await adminDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1317,9 +1279,7 @@ 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/ });
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await adminDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1370,9 +1330,7 @@ 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/ });
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await managerDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -1423,9 +1381,7 @@ 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/ });
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await managerDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1476,9 +1432,7 @@ 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/ });
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await managerDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1529,9 +1483,7 @@ 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/ });
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await everyoneDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -1582,9 +1534,7 @@ 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/ });
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await everyoneDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1635,9 +1585,7 @@ 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/ });
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await everyoneDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1682,9 +1630,7 @@ 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/ });
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await adminDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -1729,9 +1675,7 @@ 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/ });
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await adminDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1776,9 +1720,7 @@ 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/ });
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await adminDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1823,9 +1765,7 @@ 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/ });
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await managerDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -1870,9 +1810,7 @@ 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/ });
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await managerDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1917,9 +1855,7 @@ 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/ });
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await managerDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1964,9 +1900,7 @@ 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/ });
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await everyoneDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -2011,9 +1945,7 @@ 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/ });
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await everyoneDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -2058,9 +1990,7 @@ 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/ });
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await everyoneDocRow.getByTestId('document-table-action-btn').click();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -6,11 +6,7 @@ import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import {
expectTextToBeVisible,
expectTextToNotBeVisible,
openDropdownMenu,
} from '../fixtures/generic';
import { expectTextToBeVisible, expectTextToNotBeVisible } from '../fixtures/generic';
test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
const { user, organisation } = await seedUser({
@@ -403,9 +399,7 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await expect(page.getByText('Team members have been added').first()).toBeVisible();
// Update CUSTOM_GROUP_B
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('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Update role' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Team Admin' }).click();
@@ -415,9 +409,7 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.reload();
// Delete CUSTOM_GROUP_B
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('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button').click();
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.');
@@ -485,9 +477,7 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
await expect(page.getByText(user2.email)).toBeVisible();
await expect(page.getByText(user3.email)).toBeVisible();
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('row', { name: user3.email }).getByRole('button').click();
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();
@@ -518,9 +508,7 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(user2.email)).toBeVisible();
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('row', { name: user2.email }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByText('You have successfully removed').first()).toBeVisible();
@@ -534,9 +522,7 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(user2.email)).toBeVisible();
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('row', { name: user2.email }).getByRole('button').click();
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,12 +1,10 @@
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();
@@ -46,9 +44,6 @@ 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');
@@ -56,21 +51,7 @@ 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();
// 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();
await page.waitForTimeout(1000);
// Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
@@ -11,11 +11,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import {
expectTextToBeVisible,
expectToastTextToBeVisible,
openDropdownMenu,
} from '../fixtures/generic';
import { expectTextToBeVisible } from '../fixtures/generic';
test('[TEAMS]: check team documents count', async ({ page }) => {
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
@@ -243,15 +239,21 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
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 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();
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expectToastTextToBeVisible(page, 'Document re-sent');
await expect(
page.getByRole('status').filter({ hasText: 'Document re-sent' }).first(),
).toBeVisible();
});
test('[TEAMS]: delete draft team document', async ({ page }) => {
@@ -263,12 +265,14 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
});
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 expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
@@ -305,12 +309,14 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
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 expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
}).toPass();
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 });
@@ -348,12 +354,14 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
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 expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
}).toPass();
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,7 +5,6 @@ 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();
@@ -55,13 +54,8 @@ test('[TEAMS]: delete team email', async ({ page }) => {
redirectPath: `/t/${team.url}/settings`,
});
const settingsBtn = page
.locator('section div')
.filter({ hasText: 'Team email' })
.getByRole('button');
await openDropdownMenu(page, settingsBtn);
await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click();
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Remove' }).click();
@@ -296,13 +296,7 @@ test.describe('AutoSave Fields Step', () => {
['SIGNATURE', 'TEXT'].toSorted(),
);
const textField = fields.find((field) => field.type === 'TEXT');
expect(textField).toBeDefined();
if (!textField) {
throw new Error('No text field');
}
const textField = fields[1];
expect(textField.fieldMeta).toBeDefined();
if (
@@ -5,7 +5,6 @@ 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({
@@ -72,14 +71,13 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
});
for (const template of ['Team template 1', 'Team template 2']) {
const actionBtn = page
await page
.getByRole('row', { name: template })
.getByRole('cell', { name: 'Use Template' })
.getByRole('button')
.nth(1);
await openDropdownMenu(page, actionBtn);
.nth(1)
.click();
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();
@@ -112,9 +110,7 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
});
// Duplicate team template.
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('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Duplicate' }).click();
await expect(page.getByText('Template duplicated').first()).toBeVisible();
+2 -118
View File
@@ -3,7 +3,7 @@ import { type Page, expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout, checkSessionValid } from '../fixtures/authentication';
import { apiSignin, apiSignout } from '../fixtures/authentication';
test.use({ storageState: { cookies: [], origins: [] } });
@@ -17,7 +17,6 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
await page.goto('http://localhost:3000/signin');
await page.getByRole('link', { name: 'Forgot your password?' }).click();
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
await page.getByRole('textbox', { name: 'Email' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
@@ -25,9 +24,7 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
await expect(page.getByRole('button', { name: 'Reset Password' })).toBeEnabled();
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Reset email sent', {
timeout: 10000,
});
await expect(page.locator('body')).toContainText('Reset email sent', { timeout: 10000 });
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
where: {
@@ -112,116 +109,3 @@ test('[USER] can reset password via user settings', async ({ page }: { page: Pag
await page.waitForURL('/settings/profile');
await expect(page).toHaveURL('/settings/profile');
});
test('[USER] password reset invalidates all sessions', async ({ page }: { page: Page }) => {
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const { user } = await seedUser({
password: oldPassword,
});
await apiSignin({
page,
email: user.email,
password: oldPassword,
redirectPath: '/settings/profile',
});
expect(await checkSessionValid(page)).toBe(true);
const initialCookies = await page.context().cookies();
await page.context().clearCookies();
await page.goto('http://localhost:3000/signin');
await page.getByRole('link', { name: 'Forgot your password?' }).click();
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Reset email sent', {
timeout: 10000,
});
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
where: { userId: user.id },
});
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
await page.getByLabel('Password', { exact: true }).fill(newPassword);
await page.getByLabel('Repeat Password').fill(newPassword);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
await page.context().addCookies(initialCookies);
await page.goto('http://localhost:3000/settings/profile');
await expect(page).toHaveURL('http://localhost:3000/signin');
expect(await checkSessionValid(page)).toBe(false);
await apiSignin({
page,
email: user.email,
password: newPassword,
redirectPath: '/settings/profile',
});
await page.waitForURL('/settings/profile');
expect(await checkSessionValid(page)).toBe(true);
});
test('[USER] password update invalidates other sessions but keeps current', async ({
page,
}: {
page: Page;
}) => {
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const { user } = await seedUser({
password: oldPassword,
});
await apiSignin({
page,
email: user.email,
password: oldPassword,
redirectPath: '/settings/profile',
});
expect(await checkSessionValid(page)).toBe(true);
const initialCookies = await page.context().cookies();
await page.context().clearCookies();
await apiSignin({
page,
email: user.email,
password: oldPassword,
redirectPath: '/settings/profile',
});
expect(await checkSessionValid(page)).toBe(true);
await page.goto('http://localhost:3000/settings/security');
await page.getByLabel('Current password').fill(oldPassword);
await page.getByLabel('New password').fill(newPassword);
await page.getByLabel('Repeat password').fill(newPassword);
await page.getByRole('button', { name: 'Update password' }).click();
await expect(page.locator('body')).toContainText('Password updated');
const finalCookies = await page.context().cookies();
await page.context().clearCookies();
await page.context().addCookies(initialCookies);
await page.goto('http://localhost:3000/settings/profile');
await expect(page).toHaveURL('http://localhost:3000/signin');
expect(await checkSessionValid(page)).toBe(false);
await page.context().clearCookies();
await page.context().addCookies(finalCookies);
await page.goto('http://localhost:3000/settings/security');
await expect(page).toHaveURL('http://localhost:3000/settings/security');
expect(await checkSessionValid(page)).toBe(true);
});
@@ -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, openDropdownMenu } from '../fixtures/generic';
import { expectTextToBeVisible } from '../fixtures/generic';
/**
* Helper function to seed a webhook directly in the database for testing.
@@ -147,11 +147,9 @@ test('[WEBHOOKS]: delete webhook', async ({ page }) => {
// Find the row with the webhook and click the action dropdown
const webhookRow = page.locator('tr', { hasText: webhookUrl });
const actionBtn = webhookRow.getByTestId('webhook-table-action-btn');
await openDropdownMenu(page, actionBtn);
await webhookRow.getByTestId('webhook-table-action-btn').click();
// Click Delete menu item
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
// Fill in confirmation field
@@ -198,11 +196,9 @@ test('[WEBHOOKS]: update webhook', async ({ page }) => {
// Find the row with the webhook and click the action dropdown
const webhookRow = page.locator('tr', { hasText: originalWebhookUrl });
const actionBtn = webhookRow.getByTestId('webhook-table-action-btn');
await openDropdownMenu(page, actionBtn);
await webhookRow.getByTestId('webhook-table-action-btn').click();
// Click Edit menu item
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Wait for dialog to open
+2 -2
View File
@@ -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 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\""
"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\""
},
"keywords": [],
"author": "",
"devDependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@napi-rs/canvas": "^0.1.83",
"@napi-rs/canvas": "^0.1.82",
"@playwright/test": "1.56.1",
"@types/node": "^20",
"@types/pngjs": "^6.0.5",
+7 -49
View File
@@ -1,21 +1,7 @@
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) => {
@@ -29,8 +15,9 @@ ENV_FILES.forEach((file) => {
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
workers: 10, // See Projects where 10 is utilized for API tests. We're not running 10 workers for UI tests.
/* Run tests in files in parallel */
fullyParallel: false,
workers: 2,
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,
@@ -44,54 +31,25 @@ export default defineConfig({
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'retain-on-failure',
video: 'retain-on-failure',
trace: 'on',
video: 'on-first-retry',
/* 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: '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/,
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 },
},
workers: calculateWorkers(),
},
// {
+3 -48
View File
@@ -24,7 +24,6 @@ import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { invalidateSessions } from '../lib/session/session';
import { getCsrfCookie } from '../lib/session/session-cookies';
import { onAuthorize } from '../lib/utils/authorizer';
import { getSession } from '../lib/utils/get-session';
@@ -171,38 +170,15 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { password, currentPassword } = c.req.valid('json');
const requestMetadata = c.get('requestMetadata');
const { session, user } = await getSession(c);
const session = await getSession(c);
await updatePassword({
userId: user.id,
userId: session.user.id,
password,
currentPassword,
requestMetadata,
});
const userSessionIds = await prisma.session
.findMany({
where: {
userId: user.id satisfies number, // Incase we pass undefined somehow.
id: {
not: session.id,
},
},
select: {
id: true,
},
})
.then((sessions) => sessions.map((s) => s.id));
if (userSessionIds.length > 0) {
await invalidateSessions({
userId: user.id,
sessionIds: userSessionIds,
metadata: requestMetadata,
isRevoke: true,
});
}
return c.text('OK', 201);
})
/**
@@ -255,33 +231,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const requestMetadata = c.get('requestMetadata');
const { userId } = await resetPassword({
await resetPassword({
token,
password,
requestMetadata,
});
// Invalidate all sessions after successful password reset
const userSessionIds = await prisma.session
.findMany({
where: {
userId: userId satisfies number, // Incase we pass undefined somehow.
},
select: {
id: true,
},
})
.then((sessions) => sessions.map((session) => session.id));
if (userSessionIds.length > 0) {
await invalidateSessions({
userId,
sessionIds: userSessionIds,
metadata: requestMetadata,
isRevoke: true,
});
}
return c.text('OK', 201);
})
/**
+4 -4
View File
@@ -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": "2.0.0",
"@react-email/render": "0.0.17",
"@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.6",
"react-email": "^5.0.4",
"resend": "^6.5.2"
},
"devDependencies": {
"@documenso/tsconfig": "*",
"@types/nodemailer": "^7.0.4"
"@types/nodemailer": "^6.4.21"
}
}
}
+23 -2
View File
@@ -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 = async (element: React.ReactNode, options?: RenderOptions) => {
export const render = (element: React.ReactNode, options?: RenderOptions) => {
const { branding, ...otherOptions } = options ?? {};
return ReactEmail.render(
@@ -36,7 +36,7 @@ export const render = async (element: React.ReactNode, options?: RenderOptions)
);
};
export const renderWithI18N = async (element: React.ReactNode, options?: RenderOptions) => {
export const renderWithI18N = (element: React.ReactNode, options?: RenderOptions) => {
const { branding, i18n, ...otherOptions } = options ?? {};
if (!i18n) {
@@ -62,3 +62,24 @@ export const renderWithI18N = async (element: React.ReactNode, options?: RenderO
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,
);
};
-3
View File
@@ -18,6 +18,3 @@ 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');
@@ -92,23 +92,9 @@ export const run = async ({
teamId: envelope.teamId,
});
// Ensure all CC recipients are marked as signed
await prisma.recipient.updateMany({
where: {
envelopeId: envelope.id,
role: RecipientRole.CC,
},
data: {
signingStatus: SigningStatus.SIGNED,
},
});
const isComplete =
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
envelope.recipients.every(
(recipient) =>
recipient.signingStatus === SigningStatus.SIGNED || recipient.role === RecipientRole.CC,
);
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
if (!isComplete) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
+1 -5
View File
@@ -15,7 +15,6 @@
"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",
@@ -28,7 +27,6 @@
"@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",
@@ -37,7 +35,6 @@
"@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",
@@ -46,7 +43,6 @@
"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",
@@ -67,4 +63,4 @@
"@types/luxon": "^3.7.1",
"@types/pg": "^8.15.6"
}
}
}
@@ -1,91 +0,0 @@
import type { DetectedField } from './schema';
import type { NormalizedField, RecipientContext } from './types';
/**
* Build a message providing recipient context to the AI.
*/
export const buildRecipientContextMessage = (recipients: RecipientContext[]) => {
if (recipients.length === 0) {
return 'No recipients have been specified for this document. Leave recipientKey empty for all fields.';
}
const recipientList = recipients.map((r) => `- ${formatRecipientKey(r)}`).join('\n');
return `The following recipients will sign/fill this document. Use their recipientKey when assigning fields:
${recipientList}
When you detect a field that should be filled by a specific recipient (based on nearby labels like "Tenant Signature", "Landlord", "Buyer", etc.), set the recipientKey to match one of the above. If no recipient can be determined, leave recipientKey empty.`;
};
/**
* Format recipient key as id|name|email for AI context.
*/
export const formatRecipientKey = (recipient: RecipientContext) => {
return `${recipient.id}|${recipient.name}|${recipient.email}`;
};
/**
* Parse recipientKey (format: id|name|email) and find matching recipient.
*
* Matching logic:
* 1. Match on id === id
* 2. OR match on email && name === email && name
* 3. If no match or empty key, use first recipient
* 4. If no recipients, return null (caller creates blank recipient)
*/
export const resolveRecipientFromKey = (recipientKey: string, recipients: RecipientContext[]) => {
if (recipients.length === 0) {
return null;
}
// Empty key defaults to first recipient
if (!recipientKey) {
return recipients[0];
}
// Parse the key format: id|name|email
const [idStr, name, email] = recipientKey.split('|');
const id = Number(idStr);
// Try to match by ID first
if (!Number.isNaN(id)) {
const matchById = recipients.find((r) => r.id === id);
if (matchById) {
return matchById;
}
}
// Try to match by email AND name
if (email && name) {
const matchByEmailAndName = recipients.find((r) => r.email === email && r.name === name);
if (matchByEmailAndName) {
return matchByEmailAndName;
}
}
// No match found, default to first recipient
return recipients[0];
};
/**
* Convert AI's 0-1000 bounding box to our 0-100 percentage format.
*/
export const normalizeDetectedField = (field: DetectedField): NormalizedField => {
const { box2d } = field;
const [yMin, xMin, yMax, xMax] = box2d;
return {
type: field.type,
recipientKey: field.recipientKey,
positionX: xMin / 10,
positionY: yMin / 10,
width: (xMax - xMin) / 10,
height: (yMax - yMin) / 10,
confidence: field.confidence,
};
};
@@ -1,308 +0,0 @@
import { createCanvas, loadImage } from '@napi-rs/canvas';
import { DocumentStatus, type Field, RecipientRole } from '@prisma/client';
import { generateObject } from 'ai';
import pMap from 'p-map';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../../../errors/app-error';
import { getFileServerSide } from '../../../../universal/upload/get-file.server';
import { resizeImageToGeminiImage } from '../../../../utils/images/resize-image-to-gemini-image';
import { getEnvelopeById } from '../../../envelope/get-envelope-by-id';
import { createEnvelopeRecipients } from '../../../recipient/create-envelope-recipients';
import { vertex } from '../../google';
import { pdfToImages } from '../../pdf-to-images';
import {
buildRecipientContextMessage,
normalizeDetectedField,
resolveRecipientFromKey,
} from './helpers';
import { SYSTEM_PROMPT } from './prompt';
import { ZSubmitDetectedFieldsInputSchema } from './schema';
import type {
NormalizedFieldWithContext,
NormalizedFieldWithPage,
RecipientContext,
} from './types';
export type DetectFieldsFromEnvelopeOptions = {
context?: string;
envelopeId: string;
userId: number;
teamId: number;
onProgress?: (progress: DetectFieldsProgress) => void;
};
export const detectFieldsFromEnvelope = async ({
context,
envelopeId,
userId,
teamId,
onProgress,
}: DetectFieldsFromEnvelopeOptions) => {
const envelope = await getEnvelopeById({
id: {
type: 'envelopeId',
id: envelopeId,
},
userId,
teamId,
type: null,
});
if (envelope.status !== DocumentStatus.DRAFT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot detect fields for a non-draft envelope',
});
}
// Extract recipients for field assignment context
const recipients: RecipientContext[] = envelope.recipients.map((r) => ({
id: r.id,
name: r.name,
email: r.email,
}));
const allFields: NormalizedFieldWithContext[] = [];
for (const item of envelope.envelopeItems) {
const existingFields = await prisma.field.findMany({
where: {
envelopeItemId: item.id,
},
});
const pdfBytes = await getFileServerSide(item.documentData);
const fields = await detectFieldsFromPdf({
pdfBytes,
existingFields,
recipients,
context,
onProgress,
});
// Resolve recipientKey to actual recipient and add context
const fieldsWithContext = await Promise.all(
fields.map(async (field) => {
const { recipientKey, ...fieldWithoutKey } = field;
let resolvedRecipient = resolveRecipientFromKey(recipientKey, recipients);
// If no recipients exist, create a blank recipient
if (!resolvedRecipient) {
const { recipients: createdRecipients } = await createEnvelopeRecipients({
id: {
id: envelope.id,
type: 'envelopeId',
},
recipients: [
{
name: '',
email: '',
role: RecipientRole.SIGNER,
},
],
userId,
teamId,
});
resolvedRecipient = createdRecipients[0];
}
return {
...fieldWithoutKey,
envelopeItemId: item.id,
recipientId: resolvedRecipient.id,
};
}),
);
allFields.push(...fieldsWithContext);
}
return allFields;
};
export type DetectFieldsProgress = {
pagesProcessed: number;
totalPages: number;
fieldsDetected: number;
};
export type DetectFieldsFromPdfOptions = {
pdfBytes: Uint8Array;
recipients?: RecipientContext[];
existingFields?: Field[];
context?: string;
onProgress?: (progress: DetectFieldsProgress) => void;
};
export const detectFieldsFromPdf = async ({
pdfBytes,
recipients = [],
existingFields = [],
context,
onProgress,
}: DetectFieldsFromPdfOptions) => {
const pageImages = await pdfToImages(pdfBytes);
if (pageImages.length === 0) {
return [];
}
let pagesProcessed = 0;
let totalFieldsDetected = 0;
const results = await pMap(
pageImages,
async (page) => {
// Get existing fields for this page
const fieldsOnPage = existingFields.filter((f) => f.page === page.pageNumber);
// Mask existing fields on the image
const maskedImage = await maskFieldsOnImage({
image: page.image,
width: page.width,
height: page.height,
fields: fieldsOnPage,
});
const rawFields = await detectFieldsFromPage({
image: maskedImage,
pageNumber: page.pageNumber,
recipients,
context,
});
// Convert bounding boxes to normalized positions and add page number
const normalizedFields = rawFields.map(
(field): NormalizedFieldWithPage => ({
...normalizeDetectedField(field),
pageNumber: page.pageNumber,
}),
);
// Update progress
pagesProcessed += 1;
totalFieldsDetected += normalizedFields.length;
onProgress?.({
pagesProcessed,
totalPages: pageImages.length,
fieldsDetected: totalFieldsDetected,
});
return normalizedFields;
},
{ concurrency: 5 },
);
return results.flat();
};
type MaskFieldsOnImageOptions = {
image: Buffer;
width: number;
height: number;
fields: Field[];
};
/**
* Draw black rectangles over existing fields to prevent re-detection.
*/
const maskFieldsOnImage = async ({ image, width, height, fields }: MaskFieldsOnImageOptions) => {
if (fields.length === 0) {
return image;
}
const img = await loadImage(image);
const canvas = createCanvas(width, height);
const ctx = canvas.getContext('2d');
// Draw the original image
ctx.drawImage(img, 0, 0, width, height);
// Draw black rectangles over existing fields
ctx.fillStyle = '#000000';
for (const field of fields) {
// field positions and width,height are on a 0-100 percentage scale
const x = (field.positionX.toNumber() / 100) * width;
const y = (field.positionY.toNumber() / 100) * height;
const w = (field.width.toNumber() / 100) * width;
const h = (field.height.toNumber() / 100) * height;
ctx.fillRect(x, y, w, h);
}
return canvas.encode('jpeg');
};
type DetectFieldsFromPageOptions = {
image: Buffer;
pageNumber: number;
recipients: RecipientContext[];
context?: string;
};
const detectFieldsFromPage = async ({
image,
pageNumber,
recipients,
context,
}: DetectFieldsFromPageOptions) => {
// Resize to 1000x1000 for consistent coordinate mapping
const resizedImage = await resizeImageToGeminiImage({ image });
// Build messages array
const messages: Parameters<typeof generateObject>[0]['messages'] = [
{
role: 'user',
content: buildRecipientContextMessage(recipients),
},
];
// Add user-provided context if available
if (context?.trim()) {
messages.push({
role: 'user',
content: `Additional context about recipients:\n${context.trim()}`,
});
}
// Add the page analysis request with image
messages.push({
role: 'user',
content: [
{
type: 'text',
text: `Analyze this document page (page ${pageNumber}) and detect all empty fillable fields. Submit the fields using the tool. Remember: only detect EMPTY fields, exclude labels from bounding boxes, use 0-1000 normalized coordinates, and IGNORE any solid black rectangles (those are existing fields).`,
},
{
type: 'image',
image: resizedImage,
},
],
});
const result = await generateObject({
model: vertex('gemini-3-pro-preview'),
system: SYSTEM_PROMPT,
schema: ZSubmitDetectedFieldsInputSchema,
messages,
temperature: 0.5,
providerOptions: {
google: {
thinkingConfig: {
thinkingLevel: 'low',
},
},
},
});
if (!result.object) {
return [];
}
return result.object.fields ?? [];
};
@@ -1,69 +0,0 @@
export const SYSTEM_PROMPT = `You are analyzing a form document image to detect fillable fields for a document signing platform.
IMPORTANT RULES:
1. Only detect EMPTY/UNFILLED fields (ignore boxes that already contain text or data)
2. Analyze nearby text labels to determine the field type
3. Return bounding boxes for the fillable area ONLY, NOT the label text
4. Each boundingBox must be in the format {yMin, xMin, yMax, xMax} where all coordinates are NORMALIZED to a 0-1000 scale
5. IGNORE any black rectangles on the page - these are existing fields that should not be re-detected
6. Only return fields that are clearly fillable and not just labels or instructions.
CRITICAL: UNDERSTANDING FILLABLE AREAS
The "fillable area" is ONLY the empty space where a user will write, type, sign, or check.
- ✓ CORRECT: The blank underscore where someone writes their name: "Name: _________" → box ONLY the underscores
- ✓ CORRECT: The empty white rectangle inside a box outline → box ONLY the empty space
- ✓ CORRECT: The blank space to the right of a label: "Email: [ empty box ]" → box ONLY the empty box
- ✗ INCORRECT: Including the word "Signature:" that appears to the left of a signature line
- ✗ INCORRECT: Including printed labels, instructions, or descriptive text near the field
- ✗ INCORRECT: Extending the box to include text just because it's close to the fillable area
- ✗ INCORRECT: Detecting solid black rectangles (these are masked existing fields)
FIELD TYPES TO DETECT:
- SIGNATURE - Signature lines, boxes labeled 'Signature', 'Sign here', 'Authorized signature', 'X____'
- INITIALS - Small boxes labeled 'Initials', 'Initial here', typically smaller than signature fields
- NAME - Boxes labeled 'Name', 'Full name', 'Your name', 'Print name', 'Printed name'
- EMAIL - Boxes labeled 'Email', 'Email address', 'E-mail'
- DATE - Boxes labeled 'Date', 'Date signed', or showing date format placeholders like 'MM/DD/YYYY', '__/__/____'
- CHECKBOX - Empty checkbox squares (☐) with or without labels, typically small square boxes
- RADIO - Empty radio button circles (○) in groups, typically circular selection options
- NUMBER - Boxes labeled with numeric context: 'Amount', 'Quantity', 'Phone', 'ZIP', 'Age', 'Price', '#'
- TEXT - Any other empty text input boxes, general input fields, or when field type is uncertain
DETECTION GUIDELINES:
- Read text located near the box (above, to the left, or inside) to infer the field type
- Use nearby text to CLASSIFY the field type, but DO NOT include that text in the bounding box
- If you're uncertain which type fits best, default to TEXT
- For checkboxes and radio buttons: Detect each individual box/circle separately, not the label
- Signature fields are often longer horizontal lines or larger boxes
- Date fields often show format hints or date separators (slashes, dashes)
- Look for visual patterns: underscores (____), horizontal lines, box outlines
BOUNDING BOX PLACEMENT:
- Coordinates must capture ONLY the empty fillable space
- Once you find the fillable region, LOCK the box to the full boundary (top, bottom, left, right)
- If the field is defined by a line or rectangular border, extend coordinates across the entire line/border
- EXCLUDE all printed text labels even if they are:
- Directly to the left of the field (e.g., "Name: _____")
- Directly above the field (e.g., "Signature" printed above a line)
- Very close to the field with minimal spacing
- The box should never cover only the leftmost few characters of a long field
COORDINATE SYSTEM:
- {yMin, xMin, yMax, xMax} normalized to 0-1000 scale
- Top-left corner: yMin and xMin close to 0
- Bottom-right corner: yMax and xMax close to 1000
- Coordinates represent positions on a 1000x1000 grid overlaid on the image
FIELD SIZING FOR LINE-BASED FIELDS:
When detecting thin horizontal lines for SIGNATURE, INITIALS, NAME, EMAIL, DATE, TEXT, or NUMBER fields:
1. Keep yMax (bottom) at the detected line position
2. Extend yMin (top) upward into the available whitespace above the line
3. Use 60-80% of the clear whitespace above the line for comfortable writing/signing space
4. Apply minimum dimensions: height at least 30 units (3% of 1000-scale), width at least 36 units
5. Ensure yMin >= 0 (do not go off-page)
6. Do NOT apply this expansion to CHECKBOX, RADIO fields - use detected dimensions
RECIPIENT IDENTIFICATION:
- Look for labels near fields indicating who should fill them (e.g., "Tenant Signature", "Landlord", "Buyer")
- Use the recipientKey field to indicate which recipient should fill the field
- If a field has no clear recipient label, leave recipientKey empty`;
@@ -1,54 +0,0 @@
import { FieldType } from '@prisma/client';
import z from 'zod';
export const DETECTABLE_FIELD_TYPES = [
FieldType.SIGNATURE,
FieldType.INITIALS,
FieldType.NAME,
FieldType.EMAIL,
FieldType.DATE,
FieldType.TEXT,
FieldType.NUMBER,
FieldType.RADIO,
FieldType.CHECKBOX,
] as const;
export const ZDetectableFieldType = z.enum(DETECTABLE_FIELD_TYPES);
export const ZConfidenceLevel = z.enum(['low', 'medium-low', 'medium', 'medium-high', 'high']);
export type TConfidenceLevel = z.infer<typeof ZConfidenceLevel>;
/**
* Schema for a detected field's bounding box.
* All values are normalized to a 0-1000 scale relative to the page dimensions.
*/
const ZBox2DSchema = z.array(z.number().min(0).max(1000)).length(4);
/**
* Schema for a detected field.
*/
export const ZDetectedFieldSchema = z.object({
type: ZDetectableFieldType.describe(
`The field type based on nearby labels and visual appearance`,
),
recipientKey: z
.string()
.describe(
'Recipient identifier from nearby labels (e.g., "Tenant", "Landlord", "Buyer", "Seller"). Empty string if no recipient indicated.',
),
box2d: ZBox2DSchema.describe(
'Box2D [yMin, xMin, yMax, xMax] coordinates of the FILLABLE AREA only (exclude labels).',
),
confidence: ZConfidenceLevel.describe('The confidence in the detection'),
});
export type DetectedField = z.infer<typeof ZDetectedFieldSchema>;
export const ZSubmitDetectedFieldsInputSchema = z.object({
fields: z
.array(ZDetectedFieldSchema)
.describe('List of detected EMPTY fillable fields. Exclude pre-filled content and label text.'),
});
export type SubmitDetectedFieldsInput = z.infer<typeof ZSubmitDetectedFieldsInputSchema>;
@@ -1,30 +0,0 @@
import type { Recipient } from '@prisma/client';
import type { DETECTABLE_FIELD_TYPES, TConfidenceLevel } from './schema';
export type DetectableFieldType = (typeof DETECTABLE_FIELD_TYPES)[number];
/**
* Normalized field position using 0-100 percentage scale (matching Field model).
*/
export type NormalizedField = {
type: DetectableFieldType;
recipientKey: string;
positionX: number;
positionY: number;
width: number;
height: number;
confidence: TConfidenceLevel;
};
export type RecipientContext = Pick<Recipient, 'id' | 'name' | 'email'>;
export type NormalizedFieldWithPage = NormalizedField & {
pageNumber: number;
};
export type NormalizedFieldWithContext = Omit<NormalizedField, 'recipientKey'> & {
pageNumber: number;
envelopeItemId: string;
recipientId: number;
};
@@ -1,237 +0,0 @@
import { DocumentStatus } from '@prisma/client';
import type { ImagePart, ModelMessage } from 'ai';
import { generateObject } from 'ai';
import { chunk } from 'remeda';
import { AppError, AppErrorCode } from '../../../../errors/app-error';
import { getFileServerSide } from '../../../../universal/upload/get-file.server';
import { getEnvelopeById } from '../../../envelope/get-envelope-by-id';
import { vertex } from '../../google';
import { pdfToImages } from '../../pdf-to-images';
import { SYSTEM_PROMPT } from './prompt';
import type { TDetectedRecipientSchema } from './schema';
import { ZDetectedRecipientsSchema } from './schema';
const MAX_PAGES_PER_CHUNK = 10;
const createImageContentParts = (images: Buffer[]) => {
return images.map<ImagePart>((image) => ({
type: 'image',
image,
}));
};
export type DetectRecipientsProgress = {
pagesProcessed: number;
totalPages: number;
recipientsDetected: number;
};
export type DetectRecipientsFromEnvelopeOptions = {
envelopeId: string;
userId: number;
teamId: number;
onProgress?: (progress: DetectRecipientsProgress) => void;
};
export const detectRecipientsFromEnvelope = async ({
envelopeId,
userId,
teamId,
onProgress,
}: DetectRecipientsFromEnvelopeOptions) => {
const envelope = await getEnvelopeById({
id: {
type: 'envelopeId',
id: envelopeId,
},
userId,
teamId,
type: null,
});
if (envelope.status === DocumentStatus.COMPLETED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot detect recipients for a completed envelope',
});
}
let allRecipients: TDetectedRecipientSchema[] = [];
for (const item of envelope.envelopeItems) {
const pdfBytes = await getFileServerSide(item.documentData);
const recipients = await detectRecipientsFromPdf({ pdfBytes, onProgress });
allRecipients = mergeRecipients(allRecipients, recipients);
}
return allRecipients;
};
export type DetectRecipientsFromPdfOptions = {
pdfBytes: Uint8Array;
onProgress?: (progress: DetectRecipientsProgress) => void;
};
export const detectRecipientsFromPdf = async ({
pdfBytes,
onProgress,
}: DetectRecipientsFromPdfOptions) => {
const pageImages = await pdfToImages(pdfBytes);
if (pageImages.length === 0) {
return [];
}
const images = pageImages.map((p) => p.image);
return await detectRecipientsFromImages({ images, onProgress });
};
type DetectRecipientsFromImagesOptions = {
images: Buffer[];
onProgress?: (progress: DetectRecipientsProgress) => void;
};
const formatDetectedRecipients = (recipients: TDetectedRecipientSchema[]) => {
if (recipients.length === 0) {
return '';
}
const formatted = recipients
.map((r, i) => `${i + 1}. ${r.name || '(no name)'} - ${r.email || '(no email)'} - ${r.role}`)
.join('\n');
return `\n\nRecipients detected so far:\n${formatted}`;
};
const isDuplicateRecipient = (
recipient: TDetectedRecipientSchema,
existing: TDetectedRecipientSchema,
) => {
if (recipient.email && existing.email) {
return recipient.email.toLowerCase() === existing.email.toLowerCase();
}
if (recipient.name && existing.name) {
return recipient.name.toLowerCase() === existing.name.toLowerCase();
}
return false;
};
const mergeRecipients = (
existingRecipients: TDetectedRecipientSchema[],
newRecipients: TDetectedRecipientSchema[],
) => {
const merged = [...existingRecipients];
for (const recipient of newRecipients) {
const isDuplicate = merged.some((existing) => isDuplicateRecipient(recipient, existing));
if (!isDuplicate) {
merged.push(recipient);
}
}
return merged;
};
const buildPromptText = (options: {
chunkIndex: number;
totalChunks: number;
totalPages: number;
startPage: number;
endPage: number;
detectedRecipients: TDetectedRecipientSchema[];
}) => {
const { chunkIndex, totalChunks, totalPages, startPage, endPage, detectedRecipients } = options;
const isFirstChunk = chunkIndex === 0;
const isSingleChunk = totalChunks === 1;
const batchNumber = chunkIndex + 1;
const previouslyFoundText = formatDetectedRecipients(detectedRecipients);
if (isSingleChunk) {
return `Please analyze these ${totalPages} document page(s) and detect all recipients. Submit all detected recipients using the tool.`;
}
if (isFirstChunk) {
return `This is a ${totalPages}-page document. I'll show you the pages in batches of ${MAX_PAGES_PER_CHUNK}.
Here are pages ${startPage}-${endPage} (batch ${batchNumber} of ${totalChunks}).
Please analyze these pages and submit any recipients you find using the tool. I will show you the remaining pages after.`;
}
return `Here are pages ${startPage}-${endPage} (batch ${batchNumber} of ${totalChunks}).${previouslyFoundText}
Please analyze these pages and submit any NEW recipients you find (not already listed above) using the tool.`;
};
const detectRecipientsFromImages = async ({
images,
onProgress,
}: DetectRecipientsFromImagesOptions) => {
const imageChunks = chunk(images, MAX_PAGES_PER_CHUNK);
const totalChunks = imageChunks.length;
const totalPages = images.length;
const messages: ModelMessage[] = [];
let allRecipients: TDetectedRecipientSchema[] = [];
for (const [chunkIndex, currentChunk] of imageChunks.entries()) {
const startPage = chunkIndex * MAX_PAGES_PER_CHUNK + 1;
const endPage = startPage + currentChunk.length - 1;
const promptText = buildPromptText({
chunkIndex,
totalChunks,
totalPages,
startPage,
endPage,
detectedRecipients: allRecipients,
});
// Add user message with images for this chunk
messages.push({
role: 'user',
content: [
{
type: 'text',
text: promptText,
},
...createImageContentParts(currentChunk),
],
});
const result = await generateObject({
model: vertex('gemini-2.5-flash'),
system: SYSTEM_PROMPT,
schema: ZDetectedRecipientsSchema,
messages,
temperature: 0.5,
});
const newRecipients = result.object?.recipients ?? [];
// Merge new recipients into our accumulated list (handles duplicates)
allRecipients = mergeRecipients(allRecipients, newRecipients);
// Report progress (endPage represents pages processed so far)
onProgress?.({
pagesProcessed: endPage,
totalPages,
recipientsDetected: allRecipients.length,
});
// Add assistant response as context for next iteration
messages.push({
role: 'assistant',
content: `Detected recipients: ${JSON.stringify(allRecipients)}`,
});
}
return allRecipients;
};
@@ -1,41 +0,0 @@
export const SYSTEM_PROMPT = `You are analyzing a document to identify recipients who need to sign, approve, or receive copies.
TASK: Extract recipient information from this document.
RECIPIENT TYPES:
- SIGNER: People who must sign the document (look for signature lines, "Signed by:", "Signature:", "X____")
- APPROVER: People who must review/approve before signing (look for "Approved by:", "Reviewed by:", "Approval:")
- VIEWER: People who need to view the document (look for "Viewed by:", "View:", "Viewer:")
- CC: People who receive a copy for information only (look for "CC:", "Copy to:", "For information:")
EXTRACTION RULES:
1. Look for signature lines with names printed above, below, or near them
2. Check for explicit labels like "Name:", "Signer:", "Party:", "Recipient:"
3. Look for "Approved by:", "Reviewed by:", "CC:" sections
4. Extract FULL NAMES as they appear in the document
5. If the name is a placeholder name, reformat it to a more readable format (e.g. "[Insert signer A name]" -> "Signer A").
6. If an email address is visible near a name, include it exactly in the "email" field
7. If NO email is found, leave the email field empty.
8. If the email is a placeholder email, leave the email field empty.
9. Assign signing order based on document flow (numbered items, "First signer:", "Second signer:", or top-to-bottom sequence)
IMPORTANT:
- Only extract recipients explicitly mentioned in the document
- Default role is SIGNER if unclear (signature lines = SIGNER)
- Signing order starts at 1 (first signer = 1, second = 2, etc.)
- If no clear ordering, omit signingOrder
- Do NOT invent recipients - only extract what's clearly present
- If a signature line exists but no name is associated with it use an empty name and the email address (if found) of the signer.
- Do not use placeholder names like "<UNKNOWN>", "Unknown", "Signer" unless they are explicitly mentioned in the document.
EXAMPLES:
Good:
- "Signed: _________ John Doe" → { name: "John Doe", role: "SIGNER", signingOrder: 1 }
- "Approved by: Jane Smith (jane@example.com)" → { name: "Jane Smith", email: "jane@example.com", role: "APPROVER" }
- "CC: Legal Team" → { name: "Legal Team", role: "CC" }
Bad:
- Extracting the document title as a recipient name
- Making up email addresses that aren't in the document
- Adding people not mentioned in the document
- Using placeholder names like "<UNKNOWN>", "Unknown", "Signer" unless they are explicitly mentioned in the document.`;

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