mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 12:22:14 +10:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bac2bf11f4 | |||
| d93b2a70a7 | |||
| 5da915da38 | |||
| dcaecf1fc5 | |||
| f70b76d8b8 | |||
| 93137c6396 | |||
| d058b7c705 | |||
| b51f562224 | |||
| f80aa4bf72 | |||
| 9238f759a6 | |||
| 74ad6af47d | |||
| 18902ed59d |
@@ -1,14 +1,19 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: ['main', 'feat/rr7']
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e_tests:
|
||||
name: 'E2E Tests'
|
||||
timeout-minutes: 60
|
||||
runs-on: warp-ubuntu-2204-x64-16x
|
||||
runs-on: warp-ubuntu-2204-x64-8x
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -28,9 +33,6 @@ jobs:
|
||||
- name: Seed the database
|
||||
run: npm run prisma:seed
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
|
||||
- name: Install playwright browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
@@ -45,7 +47,7 @@ jobs:
|
||||
with:
|
||||
name: test-results
|
||||
path: 'packages/app-tests/**/test-results/*'
|
||||
retention-days: 30
|
||||
retention-days: 7
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
@@ -17,6 +17,7 @@ jobs:
|
||||
environment: Translations
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -26,12 +27,54 @@ jobs:
|
||||
- name: Extract translations
|
||||
run: npm run translate:extract
|
||||
|
||||
- name: Check and commit any files created
|
||||
- name: Commit changes and push to reserved branch
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="chore/extract-translations"
|
||||
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@documenso.com'
|
||||
|
||||
git fetch origin
|
||||
|
||||
# Create branch locally (always reset to main)
|
||||
git checkout -B "$BRANCH" origin/main
|
||||
|
||||
# Stage translation output
|
||||
git add packages/lib/translations
|
||||
git diff --staged --quiet --exit-code || (git commit -m "chore: extract translations" && git push)
|
||||
|
||||
# If no changes, exit early
|
||||
if git diff --staged --quiet; then
|
||||
echo "No translation changes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Commit fresh snapshot
|
||||
git commit -m "chore: extract translations"
|
||||
|
||||
# Force push reserved branch
|
||||
git push origin "$BRANCH" --force
|
||||
|
||||
# Does a PR already exist?
|
||||
EXISTING_PR=$(gh pr list \
|
||||
--state open \
|
||||
--head "$BRANCH" \
|
||||
--json number \
|
||||
--jq '.[0].number // empty')
|
||||
|
||||
if [ -z "$EXISTING_PR" ]; then
|
||||
echo "No existing PR — creating new one."
|
||||
gh pr create \
|
||||
--title "chore: extract translations" \
|
||||
--body "Automated translation extraction" \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
else
|
||||
echo "PR #$EXISTING_PR already exists — not creating a new one."
|
||||
fi
|
||||
|
||||
- name: Compile translations
|
||||
id: compile_translations
|
||||
|
||||
@@ -4,4 +4,5 @@ export default {
|
||||
'how-to': 'How To',
|
||||
'setting-up-oauth-providers': 'Setting up OAuth Providers',
|
||||
telemetry: 'Telemetry',
|
||||
'ai-features': 'AI Recipient & Field Detection',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: AI Recipient & Field Detection (Self-hosting)
|
||||
description: Configure Google Vertex AI so Documenso can detect recipients and fields automatically.
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# AI Recipient & Field Detection (Self-hosting)
|
||||
|
||||
This guide covers how to enable the AI recipient and field detection features when you self-host Documenso.
|
||||
|
||||
## What this enables
|
||||
|
||||
- Detect recipients from uploaded PDFs (roles, names, emails when present).
|
||||
- Detect and place fields (signature, initials, name, email, date, text, number, radio, checkbox) onto draft envelopes.
|
||||
- Built-in rate limits (3 requests per minute per IP) to prevent abuse.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Google Cloud project with the **Vertex AI API** enabled and billing active.
|
||||
- A **Vertex AI Express API key** with access to Gemini models (create via the [Vertex AI Express flow](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview) and manage keys in [API keys](https://cloud.google.com/vertex-ai/generative-ai/docs/start/api-keys)).
|
||||
- Documenso version that includes the AI detection feature and the corresponding database migration.
|
||||
|
||||
## Configure environment variables
|
||||
|
||||
Add these variables to your deployment `.env` (or secret manager):
|
||||
|
||||
```
|
||||
GOOGLE_VERTEX_PROJECT_ID="<your-gcp-project-id>"
|
||||
GOOGLE_VERTEX_API_KEY="<your-vertex-api-key>"
|
||||
# Optional, defaults to "global"
|
||||
GOOGLE_VERTEX_LOCATION="global"
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Use a region close to your users if you need data residency considerations (e.g. `europe-west1`).
|
||||
If you omit the location, Documenso uses `global`. Not all models are available in every region;
|
||||
if a model is unavailable, switch to a supported region.
|
||||
</Callout>
|
||||
|
||||
## Deploy with the published container
|
||||
|
||||
- Use the official Documenso image (DockerHub or GHCR) and supply the Vertex env vars above.
|
||||
- Ensure migrations run on startup (the container runs `prisma migrate deploy` in production mode).
|
||||
- Restart the container after adding or changing Vertex env vars.
|
||||
|
||||
## Enable the feature in Documenso
|
||||
|
||||
Once the service is running with the Vertex env vars:
|
||||
|
||||
<Steps>
|
||||
### Organisation settings
|
||||
|
||||
Go to **Settings → Document Preferences → AI Features** and set to **Enabled**. Teams that inherit organisation defaults will pick this up.
|
||||
|
||||
### Team settings
|
||||
|
||||
If a team overrides organisation defaults, go to **Team Settings → Document Preferences → AI Features** and choose **Enabled** (or **Inherit** to follow the organisation).
|
||||
|
||||
### Verify in the editor
|
||||
|
||||
Open a draft envelope. In **Recipients**, you should see the sparkle button for AI detection. In **Fields**, you should see **Detect with AI** available.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Too many requests**: Wait a minute or two and retry (rate limit is 3/min per IP).
|
||||
- **AI options hidden**: Ensure the env vars are set, the server was restarted after setting them, and `aiFeaturesEnabled` is enabled at organisation/team level.
|
||||
- **Detection fails immediately**: Confirm the Vertex API key is valid and the project has Vertex AI enabled. Check server logs for status codes from Vertex.
|
||||
|
||||
If issues persist, recheck env vars, restart the service, and confirm the Prisma migration was applied.
|
||||
@@ -119,6 +119,8 @@ NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
||||
```
|
||||
|
||||
For full AI setup details (including model availability notes), see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
|
||||
|
||||
### Set Up Your Signing Certificate
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -267,58 +269,63 @@ You can access the Documenso application by visiting the URL you provided for th
|
||||
|
||||
The environment variables listed above are a subset of those available for configuring Documenso. The table below provides a complete list of environment variables and their descriptions.
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
||||
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
|
||||
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
||||
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
|
||||
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
|
||||
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
|
||||
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
|
||||
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
||||
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
|
||||
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
|
||||
For AI setup specifics, see the [AI Recipient & Field Detection (Self-hosting)](./ai-features) page.
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
||||
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_ID` | The Microsoft client ID for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET` | The Microsoft client secret for Microsoft authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_ID` | The OIDC client ID for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_CLIENT_SECRET` | The OIDC client secret for OIDC authentication (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_WELL_KNOWN` | The well-known URL for the OIDC provider (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_PROVIDER_LABEL` | The label to display for the OIDC provider button (optional). |
|
||||
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | Whether to skip email verification for OIDC accounts (optional, default `false`). |
|
||||
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
||||
| `NEXT_PUBLIC_SUPPORT_EMAIL` | The support email address displayed to users (default `support@documenso.com`). |
|
||||
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
|
||||
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
|
||||
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
|
||||
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
||||
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | Whether to ignore TLS errors for the SMTP server (useful for self-signed certificates). |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |
|
||||
| `NEXT_PRIVATE_BROWSERLESS_URL` | The URL for a Browserless.io instance to generate PDFs (optional). |
|
||||
| `DOCUMENSO_DISABLE_TELEMETRY` | Set to `true` to disable anonymous telemetry (see [Telemetry](#telemetry) section below). |
|
||||
| `GOOGLE_VERTEX_PROJECT_ID` | Google Cloud project ID used for Vertex AI (required for AI detection). |
|
||||
| `GOOGLE_VERTEX_API_KEY` | Vertex AI Express API key with access to Gemini models (required for AI detection). See [AI Recipient & Field Detectionfor](./ai-features) for details. |
|
||||
| `GOOGLE_VERTEX_LOCATION` | Optional Vertex region, defaults to `global`. Not all models are available in every region. |
|
||||
|
||||
## Telemetry
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ export default {
|
||||
'document-visibility': 'Document Visibility',
|
||||
fields: 'Document Fields',
|
||||
'email-preferences': 'Email Preferences',
|
||||
'ai-detection': 'AI Recipient & Field Detection',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: AI Recipient & Field Detection
|
||||
description: Use Documenso’s AI helpers to detect recipients and fields in draft documents.
|
||||
---
|
||||
|
||||
# AI Recipient & Field Detection
|
||||
|
||||
Documenso can suggest recipients and place fields automatically using Google Vertex AI (Gemini). The feature is optional and only available when your organisation or team has **AI Features** enabled. Documents are processed securely and providers do not retain your data for training.
|
||||
|
||||
## Requirements
|
||||
|
||||
- AI Features must be enabled in **Document Preferences** for your organisation or team.
|
||||
- The envelope must be in **Draft** status.
|
||||
- Helpful rate limits are in place (up to 3 detection requests per minute per IP) to prevent abuse. If you see a “too many requests” message, wait a minute or two and try again.
|
||||
|
||||
### Enable AI features
|
||||
|
||||
1. **Organisation settings**:
|
||||
|
||||
Settings → Document Preferences → **AI Features** → Enabled.
|
||||
|
||||
_This applies to teams that inherit organisation defaults._
|
||||
|
||||
2. **Team settings**:
|
||||
|
||||
Team Settings → Document Preferences → **AI Features** → choose Enabled, Disabled, or Inherit.
|
||||
|
||||
## Detect recipients
|
||||
|
||||
Use this to identify who needs to sign or approve.
|
||||
|
||||
1. Open a draft document/template and go to the **Recipients** panel.
|
||||
2. Select the **sparkle** button to start detection. If AI is enabled, uploads launched from the dashboard will open the detector automatically.
|
||||
|
||||

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

|
||||

|
||||
|
||||
3. Watch the progress indicators; they update per page and total fields found.
|
||||
4. Review the summary and choose **Add fields** to place them in the editor.
|
||||
|
||||
Notes:
|
||||
|
||||
- Works only for draft envelopes and teams with AI features enabled.
|
||||
- Existing fields are masked during detection to avoid duplicates.
|
||||
- Fields are assigned to recipients based on nearby labels and your context message; you can edit them after adding.
|
||||
|
||||
## Best practices
|
||||
|
||||
- Keep labels near the intended fields (e.g., “Tenant signature”, “Buyer email”).
|
||||
- Provide short context when roles are ambiguous.
|
||||
- Always review suggestions before sending; AI assists but does not replace final checks.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 370 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
@@ -19,6 +19,7 @@ import * as z from 'zod';
|
||||
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -129,18 +130,43 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
const distributionMethod = watch('meta.distributionMethod');
|
||||
|
||||
const recipientsWithIndex = useMemo(
|
||||
() =>
|
||||
envelope.recipients.map((recipient, index) => ({
|
||||
...recipient,
|
||||
index,
|
||||
})),
|
||||
[envelope.recipients],
|
||||
);
|
||||
|
||||
const recipientsMissingSignatureFields = useMemo(
|
||||
() =>
|
||||
envelope.recipients.filter(
|
||||
recipientsWithIndex.filter(
|
||||
(recipient) =>
|
||||
recipient.role === RecipientRole.SIGNER &&
|
||||
!envelope.fields.some(
|
||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||
),
|
||||
),
|
||||
[envelope.recipients, envelope.fields],
|
||||
[recipientsWithIndex, envelope.fields],
|
||||
);
|
||||
|
||||
/**
|
||||
* List of recipients who must have an email due to having auth enabled.
|
||||
*/
|
||||
const recipientsMissingRequiredEmail = useMemo(() => {
|
||||
return recipientsWithIndex.filter((recipient) => {
|
||||
const auth = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
return (
|
||||
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email
|
||||
);
|
||||
});
|
||||
}, [recipientsWithIndex, envelope.authOptions]);
|
||||
|
||||
const invalidEnvelopeCode = useMemo(() => {
|
||||
if (recipientsMissingSignatureFields.length > 0) {
|
||||
return 'MISSING_SIGNATURES';
|
||||
@@ -150,8 +176,12 @@ export const EnvelopeDistributeDialog = ({
|
||||
return 'MISSING_RECIPIENTS';
|
||||
}
|
||||
|
||||
if (recipientsMissingRequiredEmail.length > 0) {
|
||||
return 'MISSING_REQUIRED_EMAIL';
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
|
||||
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
|
||||
|
||||
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
|
||||
try {
|
||||
@@ -444,7 +474,22 @@ export const EnvelopeDistributeDialog = ({
|
||||
|
||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||
{recipientsMissingSignatureFields.map((recipient) => (
|
||||
<li key={recipient.id}>{recipient.email}</li>
|
||||
<li key={recipient.id}>
|
||||
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with('MISSING_REQUIRED_EMAIL', () => (
|
||||
<AlertDescription>
|
||||
<Trans>The following recipients require an email address:</Trans>
|
||||
|
||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
||||
{recipientsMissingRequiredEmail.map((recipient) => (
|
||||
<li key={recipient.id}>
|
||||
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
const ZSignFieldEmailFormSchema = z.object({
|
||||
email: z.string().min(1, { message: msg`Email is required`.id }),
|
||||
email: z
|
||||
.string()
|
||||
.email()
|
||||
.min(1, { message: msg`Email is required`.id }),
|
||||
});
|
||||
|
||||
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -65,7 +66,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string().email(),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
@@ -100,12 +101,29 @@ export function TemplateUseDialog({
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||
defaultValues: {
|
||||
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
||||
{
|
||||
envelopeId,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = response?.data ?? [];
|
||||
|
||||
const generateDefaultFormValues = () => {
|
||||
return {
|
||||
distributeDocument: false,
|
||||
useCustomDocument: false,
|
||||
customDocumentData: [],
|
||||
customDocumentData: envelopeItems.map((item) => ({
|
||||
title: item.title,
|
||||
data: undefined,
|
||||
envelopeItemId: item.id,
|
||||
})),
|
||||
recipients: recipients
|
||||
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
|
||||
.map((recipient) => {
|
||||
@@ -124,7 +142,12 @@ export function TemplateUseDialog({
|
||||
signingOrder: recipient.signingOrder ?? undefined,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const form = useForm<TAddRecipientsForNewDocumentSchema>({
|
||||
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
|
||||
defaultValues: generateDefaultFormValues(),
|
||||
});
|
||||
|
||||
const { replace, fields: localCustomDocumentData } = useFieldArray({
|
||||
@@ -132,19 +155,6 @@ export function TemplateUseDialog({
|
||||
name: 'customDocumentData',
|
||||
});
|
||||
|
||||
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
|
||||
{
|
||||
envelopeId,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
...SKIP_QUERY_BATCH_META,
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
},
|
||||
);
|
||||
|
||||
const envelopeItems = response?.data ?? [];
|
||||
|
||||
const { mutateAsync: createDocumentFromTemplate } =
|
||||
trpc.template.createDocumentFromTemplate.useMutation();
|
||||
|
||||
@@ -214,8 +224,8 @@ export function TemplateUseDialog({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
if (open) {
|
||||
form.reset(generateDefaultFormValues());
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
@@ -322,7 +332,7 @@ export function TemplateUseDialog({
|
||||
<Input
|
||||
{...field}
|
||||
aria-label="Name"
|
||||
placeholder={recipients[index].name || _(msg`Name`)}
|
||||
placeholder={recipients[index].name || _(msg`Recipient ${index + 1}`)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -349,7 +359,7 @@ export function TemplateUseDialog({
|
||||
|
||||
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
className="ml-2 flex items-center text-sm text-muted-foreground"
|
||||
htmlFor="distributeDocument"
|
||||
>
|
||||
<Trans>Send document</Trans>
|
||||
@@ -358,7 +368,7 @@ export function TemplateUseDialog({
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
The document will be immediately sent to recipients if this
|
||||
@@ -378,7 +388,7 @@ export function TemplateUseDialog({
|
||||
|
||||
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
className="ml-2 flex items-center text-sm text-muted-foreground"
|
||||
htmlFor="distributeDocument"
|
||||
>
|
||||
<Trans>Create as pending</Trans>
|
||||
@@ -386,7 +396,7 @@ export function TemplateUseDialog({
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
Create the document as pending and ready to sign.
|
||||
@@ -432,7 +442,7 @@ export function TemplateUseDialog({
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
className="ml-2 flex items-center text-sm text-muted-foreground"
|
||||
htmlFor="useCustomDocument"
|
||||
>
|
||||
<Trans>Upload custom document</Trans>
|
||||
@@ -440,7 +450,7 @@ export function TemplateUseDialog({
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-1 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
|
||||
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
|
||||
<p>
|
||||
<Trans>
|
||||
Upload a custom document to use instead of the template's default
|
||||
@@ -470,19 +480,19 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<FileTextIcon className="text-primary h-5 w-5" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileTextIcon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-foreground truncate text-sm font-medium">
|
||||
<h4 className="truncate text-sm font-medium text-foreground">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{field.value ? (
|
||||
<div>
|
||||
<Trans>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
|
||||
|
||||
// Define the schema for configuration
|
||||
@@ -55,7 +56,7 @@ export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.exten
|
||||
nativeId: z.number().optional(),
|
||||
formId: z.string(),
|
||||
name: z.string(),
|
||||
email: z.union([z.string().length(0), z.string().email('Invalid email address')]),
|
||||
email: ZRecipientEmailSchema,
|
||||
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
|
||||
signingOrder: z.number().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
|
||||
@@ -56,13 +56,13 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
|
||||
/>
|
||||
|
||||
<div
|
||||
className="text-muted-foreground text-sm"
|
||||
className="text-sm text-muted-foreground"
|
||||
title={
|
||||
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
|
||||
}
|
||||
>
|
||||
<p>{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p>{recipient.email || recipient.name}</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+20
-18
@@ -57,12 +57,13 @@ export type DocumentSigningCompleteDialogProps = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
directTemplatePayload?: {
|
||||
recipientPayload?: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
buttonSize?: 'sm' | 'lg';
|
||||
position?: 'start' | 'end' | 'center';
|
||||
disableNameInput?: boolean;
|
||||
};
|
||||
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
@@ -89,10 +90,11 @@ export const DocumentSigningCompleteDialog = ({
|
||||
recipient,
|
||||
disabled = false,
|
||||
allowDictateNextSigner = false,
|
||||
directTemplatePayload,
|
||||
recipientPayload,
|
||||
defaultNextSigner,
|
||||
buttonSize = 'lg',
|
||||
position,
|
||||
disableNameInput = false,
|
||||
}: DocumentSigningCompleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -113,11 +115,11 @@ export const DocumentSigningCompleteDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
|
||||
const recipientForm = useForm<TDirectRecipientFormSchema>({
|
||||
resolver: zodResolver(ZDirectRecipientFormSchema),
|
||||
defaultValues: {
|
||||
name: directTemplatePayload?.name ?? '',
|
||||
email: directTemplatePayload?.email ?? '',
|
||||
name: recipientPayload?.name ?? '',
|
||||
email: recipientPayload?.email ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -145,16 +147,16 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||
try {
|
||||
let directRecipient: { name: string; email: string } | undefined;
|
||||
let recipientOverridePayload: { name: string; email: string } | undefined;
|
||||
|
||||
if (directTemplatePayload && !directTemplatePayload.email) {
|
||||
const isFormValid = await directRecipientForm.trigger();
|
||||
if (recipientPayload && !recipientPayload.email) {
|
||||
const isFormValid = await recipientForm.trigger();
|
||||
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
directRecipient = directRecipientForm.getValues();
|
||||
recipientOverridePayload = recipientForm.getValues();
|
||||
}
|
||||
|
||||
// Check if 2FA is required
|
||||
@@ -168,7 +170,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
? { name: data.name, email: data.email }
|
||||
: undefined;
|
||||
|
||||
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
|
||||
await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
|
||||
} catch (error) {
|
||||
const err = AppError.parseError(error);
|
||||
|
||||
@@ -222,7 +224,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
<div className="max-w-[50ch] text-muted-foreground">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
@@ -250,19 +252,19 @@ export const DocumentSigningCompleteDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-4 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">{documentTitle}</p>
|
||||
</div>
|
||||
|
||||
{!showTwoFactorForm && (
|
||||
<>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||
{directTemplatePayload && !directTemplatePayload.email && (
|
||||
<Form {...directRecipientForm}>
|
||||
{recipientPayload && !recipientPayload.email && (
|
||||
<Form {...recipientForm}>
|
||||
<div className="mb-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={directRecipientForm.control}
|
||||
control={recipientForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
@@ -274,7 +276,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
{...field}
|
||||
className="mt-2"
|
||||
placeholder={t`Enter your name`}
|
||||
disabled={isNameLocked}
|
||||
disabled={isNameLocked || disableNameInput}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -284,7 +286,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={directRecipientForm.control}
|
||||
control={recipientForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
|
||||
+11
-29
@@ -8,7 +8,6 @@ import {
|
||||
type SensorAPI,
|
||||
} from '@hello-pangea/dnd';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
ZRecipientAuthOptionsSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -70,10 +70,7 @@ const ZEnvelopeRecipientsForm = z.object({
|
||||
z.object({
|
||||
formId: z.string().min(1),
|
||||
id: z.number().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.min(1),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
@@ -228,12 +225,13 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
keyName: 'nativeId',
|
||||
});
|
||||
|
||||
const emptySigners = useCallback(
|
||||
() => form.getValues('signers').filter((signer) => signer.email === ''),
|
||||
[form],
|
||||
const emptySignerIndex = watchedSigners.findIndex(
|
||||
(signer) =>
|
||||
!signer.name &&
|
||||
!signer.email &&
|
||||
envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
|
||||
);
|
||||
|
||||
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
||||
const isUserAlreadyARecipient = watchedSigners.some(
|
||||
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
|
||||
);
|
||||
@@ -558,21 +556,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const formValueSigners = formValues.signers || [];
|
||||
|
||||
// Remove the last signer if it's empty.
|
||||
const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
|
||||
if (i === formValueSigners.length - 1 && signer.email === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
|
||||
...formValues,
|
||||
signers: nonEmptyRecipients,
|
||||
});
|
||||
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
|
||||
|
||||
if (!validatedFormValues.success) {
|
||||
return;
|
||||
@@ -736,9 +720,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
|
||||
}
|
||||
disabled={isSubmitting || hasDocumentBeenSent}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -924,7 +906,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
})}
|
||||
>
|
||||
{!showAdvancedSettings && index === 0 && (
|
||||
<FormLabel required>
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
</FormLabel>
|
||||
)}
|
||||
@@ -978,7 +960,7 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
<FormControl>
|
||||
<RecipientAutoCompleteInput
|
||||
type="text"
|
||||
placeholder={t`Name`}
|
||||
placeholder={t`Recipient ${index + 1}`}
|
||||
{...field}
|
||||
disabled={
|
||||
snapshot.isDragging ||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
@@ -39,8 +41,15 @@ export const EnvelopeRecipientSelector = ({
|
||||
fields,
|
||||
align = 'start',
|
||||
}: EnvelopeRecipientSelectorProps) => {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const getRecipientLabel = useCallback(
|
||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
[recipients],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -49,7 +58,7 @@ export const EnvelopeRecipientSelector = ({
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
|
||||
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
|
||||
getRecipientColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === selectedRecipient?.id),
|
||||
@@ -59,16 +68,12 @@ export const EnvelopeRecipientSelector = ({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{selectedRecipient?.email && (
|
||||
{selectedRecipient && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedRecipient?.name} ({selectedRecipient?.email})
|
||||
{getRecipientLabel(selectedRecipient)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!selectedRecipient?.email && (
|
||||
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
|
||||
)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -105,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
fields,
|
||||
placeholder,
|
||||
}: EnvelopeRecipientSelectorCommandProps) => {
|
||||
const { t } = useLingui();
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
const recipientsByRole = useCallback(() => {
|
||||
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||
@@ -154,6 +159,11 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
[fields, recipients],
|
||||
);
|
||||
|
||||
const getRecipientLabel = useCallback(
|
||||
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
|
||||
[recipients],
|
||||
);
|
||||
|
||||
return (
|
||||
<Command
|
||||
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
|
||||
@@ -162,21 +172,21 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
<CommandInput placeholder={placeholder} />
|
||||
|
||||
<CommandEmpty>
|
||||
<span className="text-muted-foreground inline-block px-4">
|
||||
<span className="inline-block px-4 text-muted-foreground">
|
||||
<Trans>No recipient matching this description was found.</Trans>
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
|
||||
<CommandGroup key={roleIndex}>
|
||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
|
||||
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
|
||||
</div>
|
||||
|
||||
{roleRecipients.length === 0 && (
|
||||
<div
|
||||
key={`${role}-empty`}
|
||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
|
||||
>
|
||||
<Trans>No recipients with this role</Trans>
|
||||
</div>
|
||||
@@ -205,18 +215,12 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn('text-foreground/70 truncate', {
|
||||
className={cn('truncate text-foreground/70', {
|
||||
'text-foreground/80': recipient.id === selectedRecipient?.id,
|
||||
'opacity-50': isRecipientDisabled(recipient.id),
|
||||
})}
|
||||
>
|
||||
{recipient.name && (
|
||||
<span title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
|
||||
{getRecipientLabel(recipient)}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center justify-center">
|
||||
@@ -234,7 +238,7 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
<Info className="z-50 ml-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<TooltipContent className="max-w-xs text-muted-foreground">
|
||||
<Trans>
|
||||
This document has already been sent to this recipient. You can no longer
|
||||
edit this recipient.
|
||||
@@ -250,3 +254,22 @@ export const EnvelopeRecipientSelectorCommand = ({
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
|
||||
if (recipient.name && recipient.email) {
|
||||
return `${recipient.name} (${recipient.email})`;
|
||||
}
|
||||
|
||||
if (recipient.name) {
|
||||
return recipient.name;
|
||||
}
|
||||
|
||||
if (recipient.email) {
|
||||
return recipient.email;
|
||||
}
|
||||
|
||||
// Since objects are basically pointers we can use `indexOf` rather than `findIndex`
|
||||
const index = recipients.indexOf(recipient);
|
||||
|
||||
return i18n._(msg`Recipient ${index + 1}`);
|
||||
};
|
||||
|
||||
+16
-4
@@ -80,12 +80,14 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
const handleOnCompleteClick = async (
|
||||
nextSigner?: { name: string; email: string },
|
||||
accessAuthOptions?: TRecipientAccessAuth,
|
||||
recipientDetails?: { name: string; email: string },
|
||||
) => {
|
||||
try {
|
||||
await completeDocument({
|
||||
token: recipient.token,
|
||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
accessAuthOptions,
|
||||
recipientOverride: recipientDetails,
|
||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||
});
|
||||
|
||||
@@ -205,21 +207,30 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const directTemplatePayload = useMemo(() => {
|
||||
const recipientPayload = useMemo(() => {
|
||||
if (!isDirectTemplate) {
|
||||
return;
|
||||
return {
|
||||
name:
|
||||
recipient.name ||
|
||||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
'',
|
||||
email:
|
||||
recipient.email ||
|
||||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
|
||||
'',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: fullName,
|
||||
email: email,
|
||||
};
|
||||
}, [email, fullName, isDirectTemplate]);
|
||||
}, [email, fullName, isDirectTemplate, recipient.email, recipient.name, recipient.fields]);
|
||||
|
||||
return (
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isPending}
|
||||
directTemplatePayload={directTemplatePayload}
|
||||
recipientPayload={recipientPayload}
|
||||
onSignatureComplete={
|
||||
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
|
||||
}
|
||||
@@ -230,6 +241,7 @@ export const EnvelopeSignerCompleteDialog = () => {
|
||||
allowDictateNextSigner={Boolean(
|
||||
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
|
||||
)}
|
||||
disableNameInput={!isDirectTemplate && recipient.name !== ''}
|
||||
defaultNextSigner={
|
||||
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@ export const StackAvatarsWithTooltip = ({
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -107,8 +107,8 @@ export const StackAvatarsWithTooltip = ({
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+16
-2
@@ -46,12 +46,16 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
|
||||
const { getTheme } = await themeSessionResolver(request);
|
||||
|
||||
let lang: SupportedLanguageCodes = await langCookie.parse(request.headers.get('cookie') ?? '');
|
||||
const cookieHeader = request.headers.get('cookie') ?? '';
|
||||
|
||||
let lang: SupportedLanguageCodes = await langCookie.parse(cookieHeader);
|
||||
|
||||
if (!APP_I18N_OPTIONS.supportedLangs.includes(lang)) {
|
||||
lang = extractLocaleData({ headers: request.headers }).lang;
|
||||
}
|
||||
|
||||
const disableAnimations = cookieHeader.includes('__disable_animations=true');
|
||||
|
||||
let organisations = null;
|
||||
|
||||
if (session.isAuthenticated) {
|
||||
@@ -62,6 +66,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
{
|
||||
lang,
|
||||
theme: getTheme(),
|
||||
disableAnimations,
|
||||
session: session.isAuthenticated
|
||||
? {
|
||||
user: session.user,
|
||||
@@ -92,7 +97,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const { publicEnv, session, lang, ...data } = useLoaderData<typeof loader>() || {};
|
||||
const { publicEnv, session, lang, disableAnimations, ...data } =
|
||||
useLoaderData<typeof loader>() || {};
|
||||
|
||||
const [theme] = useTheme();
|
||||
|
||||
@@ -111,6 +117,14 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<meta name="google" content="notranslate" />
|
||||
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
|
||||
|
||||
{disableAnimations && (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
|
||||
<script>0</script>
|
||||
</head>
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import DocumentPage, { meta } from '../../o.$orgUrl.settings.document';
|
||||
import DocumentPage, { loader, meta } from '../../o.$orgUrl.settings.document';
|
||||
|
||||
export { meta };
|
||||
export { meta, loader };
|
||||
|
||||
export default DocumentPage;
|
||||
|
||||
@@ -28,8 +28,6 @@ export const loader = () => {
|
||||
export default function TeamsSettingsPage() {
|
||||
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
|
||||
|
||||
console.log('isAiFeaturesConfigured', isAiFeaturesConfigured);
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -41,7 +41,9 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
const token = url.searchParams.get('token') || '';
|
||||
|
||||
// We also know that the token is valid, but we need the userId + teamId
|
||||
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
|
||||
const result = await verifyEmbeddingPresignToken({ token, scope: `documentId:${id}` }).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Invalid token');
|
||||
|
||||
@@ -41,7 +41,9 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
const token = url.searchParams.get('token') || '';
|
||||
|
||||
// We also know that the token is valid, but we need the userId + teamId
|
||||
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
|
||||
const result = await verifyEmbeddingPresignToken({ token, scope: `templateId:${id}` }).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('Invalid token');
|
||||
|
||||
@@ -108,5 +108,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.2.3"
|
||||
"version": "2.2.5"
|
||||
}
|
||||
|
||||
Generated
+277
-533
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.2.3",
|
||||
"version": "2.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.2.3",
|
||||
"version": "2.2.5",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.2.3",
|
||||
"version": "2.2.5",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
@@ -666,58 +666,6 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-ses": {
|
||||
"version": "3.936.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.936.0.tgz",
|
||||
"integrity": "sha512-2toHYwRkcYGasPHYGwOwaIAa2Api/uFhmL3px0Tyt4bne2ilqhSwq+6a/0UVMd8JYwWaLMJolTbWKFt2jUlmGg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-browser": "5.2.0",
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
"@aws-sdk/core": "3.936.0",
|
||||
"@aws-sdk/credential-provider-node": "3.936.0",
|
||||
"@aws-sdk/middleware-host-header": "3.936.0",
|
||||
"@aws-sdk/middleware-logger": "3.936.0",
|
||||
"@aws-sdk/middleware-recursion-detection": "3.936.0",
|
||||
"@aws-sdk/middleware-user-agent": "3.936.0",
|
||||
"@aws-sdk/region-config-resolver": "3.936.0",
|
||||
"@aws-sdk/types": "3.936.0",
|
||||
"@aws-sdk/util-endpoints": "3.936.0",
|
||||
"@aws-sdk/util-user-agent-browser": "3.936.0",
|
||||
"@aws-sdk/util-user-agent-node": "3.936.0",
|
||||
"@smithy/config-resolver": "^4.4.3",
|
||||
"@smithy/core": "^3.18.5",
|
||||
"@smithy/fetch-http-handler": "^5.3.6",
|
||||
"@smithy/hash-node": "^4.2.5",
|
||||
"@smithy/invalid-dependency": "^4.2.5",
|
||||
"@smithy/middleware-content-length": "^4.2.5",
|
||||
"@smithy/middleware-endpoint": "^4.3.12",
|
||||
"@smithy/middleware-retry": "^4.4.12",
|
||||
"@smithy/middleware-serde": "^4.2.6",
|
||||
"@smithy/middleware-stack": "^4.2.5",
|
||||
"@smithy/node-config-provider": "^4.3.5",
|
||||
"@smithy/node-http-handler": "^4.4.5",
|
||||
"@smithy/protocol-http": "^5.3.5",
|
||||
"@smithy/smithy-client": "^4.9.8",
|
||||
"@smithy/types": "^4.9.0",
|
||||
"@smithy/url-parser": "^4.2.5",
|
||||
"@smithy/util-base64": "^4.3.0",
|
||||
"@smithy/util-body-length-browser": "^4.2.0",
|
||||
"@smithy/util-body-length-node": "^4.2.1",
|
||||
"@smithy/util-defaults-mode-browser": "^4.3.11",
|
||||
"@smithy/util-defaults-mode-node": "^4.2.14",
|
||||
"@smithy/util-endpoints": "^3.2.5",
|
||||
"@smithy/util-middleware": "^4.2.5",
|
||||
"@smithy/util-retry": "^4.2.5",
|
||||
"@smithy/util-utf8": "^4.2.0",
|
||||
"@smithy/util-waiter": "^4.2.5",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-sesv2": {
|
||||
"version": "3.936.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.936.0.tgz",
|
||||
@@ -5963,12 +5911,6 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@one-ini/wasm": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -12161,6 +12103,7 @@
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@@ -14407,21 +14350,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-email/render": {
|
||||
"version": "0.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.17.tgz",
|
||||
"integrity": "sha512-xBQ+/73+WsGuXKY7r1U73zMBNV28xdV0cp9cFjhNYipBReDHhV97IpA6v7Hl0dDtDzt+yS/72dY5vYXrF1v8NA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.0.tgz",
|
||||
"integrity": "sha512-rdjNj6iVzv8kRKDPFas+47nnoe6B40+nwukuXwY4FCwM7XBg6tmYr+chQryCuavUj2J65MMf6fztk1bxOUiSVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"html-to-text": "9.0.5",
|
||||
"js-beautify": "^1.14.11",
|
||||
"react-promise-suspense": "0.3.4"
|
||||
"html-to-text": "^9.0.5",
|
||||
"prettier": "^3.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-email/row": {
|
||||
@@ -17107,13 +17049,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.21.tgz",
|
||||
"integrity": "sha512-Eix+sb/Nj28MNnWvO2X1OLrk5vuD4C9SMnb2Vf4itWnxphYeSceqkFX7IdmxTzn+dvmnNz7paMbg4Uc60wSfJg==",
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.4.tgz",
|
||||
"integrity": "sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ses": "^3.731.1",
|
||||
"@aws-sdk/client-sesv2": "^3.839.0",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
@@ -17787,15 +17729,6 @@
|
||||
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
|
||||
"integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -19861,6 +19794,7 @@
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -20063,22 +19997,6 @@
|
||||
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ini": "^1.3.4",
|
||||
"proto-list": "~1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/config-chain/node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||
@@ -21571,48 +21489,6 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
|
||||
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@one-ini/wasm": "0.1.1",
|
||||
"commander": "^10.0.0",
|
||||
"minimatch": "9.0.1",
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"bin": {
|
||||
"editorconfig": "bin/editorconfig"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/editorconfig/node_modules/minimatch": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
|
||||
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -26314,117 +26190,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify": {
|
||||
"version": "1.15.4",
|
||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
|
||||
"integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"config-chain": "^1.1.13",
|
||||
"editorconfig": "^1.0.4",
|
||||
"glob": "^10.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nopt": "^7.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"css-beautify": "js/bin/css-beautify.js",
|
||||
"html-beautify": "js/bin/html-beautify.js",
|
||||
"js-beautify": "js/bin/js-beautify.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify/node_modules/path-scurry": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-sdsl": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
|
||||
@@ -29480,21 +29245,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
|
||||
"integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"abbrev": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"nopt": "bin/nopt.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-package-data": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-5.0.0.tgz",
|
||||
@@ -31586,12 +31336,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
@@ -31927,263 +31671,6 @@
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-email/-/react-email-5.0.4.tgz",
|
||||
"integrity": "sha512-pI6b4ePMWC1AFtyd0gyVMsJ6Nhg4nnmDxVo1EQP+r011WQafU8UGAUfPUiE5Wh7x28+3yFw7qXCnD33y71guUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/traverse": "^7.27.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"commander": "^13.0.0",
|
||||
"conf": "^15.0.2",
|
||||
"debounce": "^2.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"glob": "^11.0.0",
|
||||
"jiti": "2.4.2",
|
||||
"log-symbols": "^7.0.0",
|
||||
"mime-types": "^3.0.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"nypm": "0.6.0",
|
||||
"ora": "^8.0.0",
|
||||
"prompts": "2.4.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"tsconfig-paths": "4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"email": "dist/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/commander": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
||||
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-email/node_modules/is-interactive": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
|
||||
"integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/is-unicode-supported": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
|
||||
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/jiti": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/log-symbols": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
||||
"integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-unicode-supported": "^2.0.0",
|
||||
"yoctocolors": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/mime-types": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/ora": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
|
||||
"integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"cli-cursor": "^5.0.0",
|
||||
"cli-spinners": "^2.9.2",
|
||||
"is-interactive": "^2.0.0",
|
||||
"is-unicode-supported": "^2.0.0",
|
||||
"log-symbols": "^6.0.0",
|
||||
"stdin-discarder": "^0.2.2",
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/ora/node_modules/log-symbols": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
|
||||
"integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"is-unicode-supported": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
|
||||
"integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-email/node_modules/tsconfig-paths": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
|
||||
"integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json5": "^2.2.2",
|
||||
"minimist": "^1.2.6",
|
||||
"strip-bom": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.66.1",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
|
||||
@@ -37453,18 +36940,275 @@
|
||||
"@react-email/img": "0.0.11",
|
||||
"@react-email/link": "0.0.12",
|
||||
"@react-email/preview": "0.0.13",
|
||||
"@react-email/render": "0.0.17",
|
||||
"@react-email/render": "2.0.0",
|
||||
"@react-email/row": "0.0.12",
|
||||
"@react-email/section": "0.0.16",
|
||||
"@react-email/tailwind": "^2.0.1",
|
||||
"@react-email/text": "0.1.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"react-email": "^5.0.4",
|
||||
"react-email": "^5.0.6",
|
||||
"resend": "^6.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/nodemailer": "^6.4.21"
|
||||
"@types/nodemailer": "^7.0.4"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/commander": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
||||
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/email/node_modules/is-interactive": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
|
||||
"integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/is-unicode-supported": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
|
||||
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/jiti": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/log-symbols": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
||||
"integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-unicode-supported": "^2.0.0",
|
||||
"yoctocolors": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/mime-types": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/ora": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
|
||||
"integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"cli-cursor": "^5.0.0",
|
||||
"cli-spinners": "^2.9.2",
|
||||
"is-interactive": "^2.0.0",
|
||||
"is-unicode-supported": "^2.0.0",
|
||||
"log-symbols": "^6.0.0",
|
||||
"stdin-discarder": "^0.2.2",
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/ora/node_modules/log-symbols": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
|
||||
"integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"is-unicode-supported": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
|
||||
"integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/react-email": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/react-email/-/react-email-5.0.6.tgz",
|
||||
"integrity": "sha512-DEGzWpEiC3CquPEaaEJuipNT3WZ9mK58rbkpOe4Slbgyf60PLa1wONnt5a3afbBBRbNdW2aYhIvVI41yS6UIRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/traverse": "^7.27.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"commander": "^13.0.0",
|
||||
"conf": "^15.0.2",
|
||||
"debounce": "^2.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"glob": "^11.0.0",
|
||||
"jiti": "2.4.2",
|
||||
"log-symbols": "^7.0.0",
|
||||
"mime-types": "^3.0.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"nypm": "0.6.0",
|
||||
"ora": "^8.0.0",
|
||||
"prompts": "2.4.2",
|
||||
"socket.io": "^4.8.1",
|
||||
"tsconfig-paths": "4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"email": "dist/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/strip-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"packages/email/node_modules/tsconfig-paths": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
|
||||
"integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json5": "^2.2.2",
|
||||
"minimist": "^1.2.6",
|
||||
"strip-bom": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"packages/eslint-config": {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.2.3",
|
||||
"version": "2.2.5",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { APIRequestContext } from 'playwright-core';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import type { CreateEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||
import type { VerifyEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
@@ -17,18 +20,7 @@ test.describe('Embedding Presign API', () => {
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await createPresignToken(request, token);
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
@@ -54,19 +46,9 @@ test.describe('Embedding Presign API', () => {
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
expiresIn: 120, // 2 hours
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await createPresignToken(request, token, {
|
||||
expiresIn: 120, // 2 hours
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
@@ -92,19 +74,9 @@ test.describe('Embedding Presign API', () => {
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
expiresIn: 0, // Immediate expiration
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await createPresignToken(request, token, {
|
||||
expiresIn: 0, // Immediate expiration
|
||||
});
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
@@ -129,18 +101,7 @@ test.describe('Embedding Presign API', () => {
|
||||
});
|
||||
|
||||
// First create a token
|
||||
const createResponse = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
},
|
||||
},
|
||||
);
|
||||
const createResponse = await createPresignToken(request, token);
|
||||
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createResponseData = await createResponse.json();
|
||||
@@ -150,18 +111,9 @@ test.describe('Embedding Presign API', () => {
|
||||
const presignToken = createResponseData.token;
|
||||
|
||||
// Then verify it
|
||||
const verifyResponse = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
token: presignToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
const verifyResponse = await verifyPresignToken(request, token, {
|
||||
token: presignToken,
|
||||
});
|
||||
|
||||
expect(verifyResponse.ok()).toBeTruthy();
|
||||
expect(verifyResponse.status()).toBe(200);
|
||||
@@ -183,18 +135,87 @@ test.describe('Embedding Presign API', () => {
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
token: 'invalid-token',
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await verifyPresignToken(request, token, {
|
||||
token: 'invalid-token',
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
console.log('Invalid token response:', responseData);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
expect(responseData.success).toBe(false);
|
||||
});
|
||||
|
||||
test('verifyEmbeddingPresignToken: should verify a valid scoped token', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// First create a token
|
||||
const createResponse = await createPresignToken(request, token, {
|
||||
scope: 'documentId:1',
|
||||
});
|
||||
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createResponseData = await createResponse.json();
|
||||
|
||||
console.log('Create response:', createResponseData);
|
||||
|
||||
const presignToken = createResponseData.token;
|
||||
|
||||
// Then verify it
|
||||
const verifyResponse = await verifyPresignToken(request, token, {
|
||||
token: presignToken,
|
||||
scope: 'documentId:1',
|
||||
});
|
||||
|
||||
expect(verifyResponse.ok()).toBeTruthy();
|
||||
expect(verifyResponse.status()).toBe(200);
|
||||
|
||||
const verifyResponseData = await verifyResponse.json();
|
||||
|
||||
console.log('Verify response:', verifyResponseData);
|
||||
|
||||
expect(verifyResponseData.success).toBe(true);
|
||||
});
|
||||
|
||||
test('verifyEmbeddingPresignToken: should reject a scope mismatched token', async ({
|
||||
request,
|
||||
}) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// First create a token
|
||||
const createResponse = await createPresignToken(request, token, {
|
||||
scope: 'documentId:1',
|
||||
});
|
||||
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createResponseData = await createResponse.json();
|
||||
|
||||
console.log('Create response:', createResponseData);
|
||||
|
||||
const presignToken = createResponseData.token;
|
||||
|
||||
// Then verify it
|
||||
const response = await verifyPresignToken(request, token, {
|
||||
token: presignToken,
|
||||
scope: 'documentId:2',
|
||||
});
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
@@ -206,3 +227,40 @@ test.describe('Embedding Presign API', () => {
|
||||
expect(responseData.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const createPresignToken = async (
|
||||
request: APIRequestContext,
|
||||
apiToken: string,
|
||||
data?: Partial<CreateEmbeddingPresignTokenOptions>,
|
||||
) => {
|
||||
return await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken,
|
||||
...data,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const verifyPresignToken = async (
|
||||
request: APIRequestContext,
|
||||
apiToken: string,
|
||||
data: VerifyEmbeddingPresignTokenOptions,
|
||||
) => {
|
||||
return await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { pick } from 'remeda';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentDistributionMethod,
|
||||
@@ -23,7 +24,9 @@ import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||
import type { TUpdateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
|
||||
|
||||
@@ -144,6 +147,9 @@ test.describe('API V2 Envelopes', () => {
|
||||
externalId: 'externalId',
|
||||
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
globalAccessAuth: ['ACCOUNT'],
|
||||
// Ignore this error in the test since it doesn't actually exist in the PDF:
|
||||
// - Error setting value for field hello: PDFDocument has no form field with the name "hello"
|
||||
// We want to check if the form value is set in the DB.
|
||||
formValues: {
|
||||
hello: 'world',
|
||||
},
|
||||
@@ -262,8 +268,6 @@ test.describe('API V2 Envelopes', () => {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(userB.email);
|
||||
|
||||
expect(envelope.envelopeItems.length).toBe(2);
|
||||
expect(envelope.envelopeItems[0].title).toBe('field-meta.pdf');
|
||||
expect(envelope.envelopeItems[1].title).toBe('field-font-alignment.pdf');
|
||||
@@ -557,4 +561,543 @@ test.describe('API V2 Envelopes', () => {
|
||||
userEmail: userA.email,
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Empty recipient tests', () => {
|
||||
test('Create template envelope with empty email recipient', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title: 'Template with Empty Email Recipient',
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const response = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Create recipient with empty email
|
||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId: response.id,
|
||||
data: [
|
||||
{
|
||||
email: '',
|
||||
name: 'Test Recipient',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
||||
expect(createRecipientsRes.status()).toBe(200);
|
||||
|
||||
const recipientsResponse = await createRecipientsRes.json();
|
||||
const recipient = recipientsResponse.data[0];
|
||||
|
||||
expect(recipient.email).toBe('');
|
||||
expect(recipient.name).toBe('Test Recipient');
|
||||
|
||||
// Get envelope items to assign fields
|
||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${response.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
// Create field for the recipient with empty email
|
||||
const createFieldsRequest = {
|
||||
envelopeId: response.id,
|
||||
data: [
|
||||
{
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 100,
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createFieldsRequest,
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
expect(createFieldsRes.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('Create document envelope with empty email recipient', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Document with Empty Email Recipient',
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const response = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Create recipient with empty email
|
||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId: response.id,
|
||||
data: [
|
||||
{
|
||||
email: '',
|
||||
name: 'Document Recipient No Email',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
||||
const recipientsResponse = await createRecipientsRes.json();
|
||||
const recipient = recipientsResponse.data[0];
|
||||
|
||||
expect(recipient.email).toBe('');
|
||||
expect(recipient.name).toBe('Document Recipient No Email');
|
||||
});
|
||||
|
||||
test('Update recipient to have empty email', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title: 'Update Recipient Email Test',
|
||||
recipients: [
|
||||
{
|
||||
email: userA.email,
|
||||
name: 'Test User',
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
],
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Get the envelope to get recipient ID
|
||||
const getRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const envelope: TGetEnvelopeResponse = await getRes.json();
|
||||
const recipientId = envelope.recipients[0].id;
|
||||
|
||||
// Update recipient to have empty email
|
||||
const updateRequest: TUpdateEnvelopeRecipientsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: [
|
||||
{
|
||||
id: recipientId,
|
||||
email: '',
|
||||
name: 'Updated Name No Email',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const updateRes = await request.post(`${baseUrl}/envelope/recipient/update-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: updateRequest,
|
||||
});
|
||||
|
||||
expect(updateRes.ok()).toBeTruthy();
|
||||
const updateResponse = await updateRes.json();
|
||||
const updatedRecipient = updateResponse.data[0];
|
||||
|
||||
expect(updatedRecipient.email).toBe('');
|
||||
expect(updatedRecipient.name).toBe('Updated Name No Email');
|
||||
});
|
||||
|
||||
test('Mixed recipients with and without emails', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
title: 'Mixed Recipients Test',
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const response = (await res.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Create multiple recipients, some with email, some without
|
||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId: response.id,
|
||||
data: [
|
||||
{
|
||||
email: userA.email,
|
||||
name: 'Recipient With Email',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
{
|
||||
email: '',
|
||||
name: 'Recipient Without Email 1',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
{
|
||||
email: userB.email,
|
||||
name: 'Another With Email',
|
||||
role: RecipientRole.APPROVER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
{
|
||||
email: '',
|
||||
name: 'Recipient Without Email 2',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
||||
const recipientsResponse = await createRecipientsRes.json();
|
||||
const recipients = recipientsResponse.data;
|
||||
|
||||
expect(recipients.length).toBe(4);
|
||||
expect(recipients[0].email).toBe(userA.email.toLowerCase());
|
||||
expect(recipients[1].email).toBe('');
|
||||
expect(recipients[2].email).toBe(userB.email.toLowerCase());
|
||||
expect(recipients[3].email).toBe('');
|
||||
|
||||
// Get envelope to assign fields
|
||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${response.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
// Create fields for all recipients including those without emails
|
||||
const createFieldsRequest = {
|
||||
envelopeId: response.id,
|
||||
data: recipients.map((recipient, index) => ({
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 0 + index,
|
||||
width: 50,
|
||||
height: 50,
|
||||
})),
|
||||
};
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createFieldsRequest,
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Distribute envelope with empty email recipients', async ({ request }) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Document for Distribution with Empty Email',
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Create recipients with empty emails
|
||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: [
|
||||
{
|
||||
email: '',
|
||||
name: 'Recipient One',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
{
|
||||
email: '',
|
||||
name: 'Recipient Two',
|
||||
role: RecipientRole.APPROVER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
||||
const recipientsResponse = await createRecipientsRes.json();
|
||||
const recipients = recipientsResponse.data;
|
||||
|
||||
// Get envelope to assign fields
|
||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
// Create fields for recipients
|
||||
const createFieldsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: recipients.map((recipient, index) => ({
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 0 + index,
|
||||
width: 50,
|
||||
height: 50,
|
||||
})),
|
||||
};
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createFieldsRequest,
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
// Distribute the envelope
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: createResponse.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
const distributeResponse = await distributeRes.json();
|
||||
expect(distributeResponse.success).toBe(true);
|
||||
expect(distributeResponse.id).toBe(createResponse.id);
|
||||
expect(distributeResponse.recipients).toHaveLength(2);
|
||||
|
||||
// Verify recipients have empty emails and signing URLs
|
||||
expect(distributeResponse.recipients[0].email).toBe('');
|
||||
expect(distributeResponse.recipients[0].signingUrl).toBeTruthy();
|
||||
expect(distributeResponse.recipients[1].email).toBe('');
|
||||
expect(distributeResponse.recipients[1].signingUrl).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Distribute envelope with empty email recipient and auth requirements fails', async ({
|
||||
request,
|
||||
}) => {
|
||||
const payload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Document with Auth Requirements',
|
||||
} satisfies TCreateEnvelopePayload;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
|
||||
const files = [
|
||||
{
|
||||
name: 'example.pdf',
|
||||
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
|
||||
},
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
const createRes = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
// Create recipient with empty email and TWO_FACTOR_AUTH action auth
|
||||
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: [
|
||||
{
|
||||
email: '',
|
||||
name: 'Recipient With Auth',
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [DocumentAccessAuth.TWO_FACTOR_AUTH],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(createRecipientsRes.ok()).toBeTruthy();
|
||||
const recipientsResponse = await createRecipientsRes.json();
|
||||
const recipient = recipientsResponse.data[0];
|
||||
|
||||
// Get envelope to assign fields
|
||||
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
});
|
||||
|
||||
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
|
||||
const envelopeItem = envelope.envelopeItems[0];
|
||||
|
||||
// Create field for the recipient
|
||||
const createFieldsRequest = {
|
||||
envelopeId: createResponse.id,
|
||||
data: [
|
||||
{
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId: envelopeItem.id,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 100,
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: createFieldsRequest,
|
||||
});
|
||||
|
||||
expect(createFieldsRes.ok()).toBeTruthy();
|
||||
|
||||
// Try to distribute the envelope - should fail
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: createResponse.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Expect distribution to fail
|
||||
expect(distributeRes.ok()).toBeFalsy();
|
||||
expect(distributeRes.status()).toBe(400);
|
||||
|
||||
const errorResponse = await distributeRes.json();
|
||||
expect(errorResponse.message).toContain('requires an email');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3103,7 +3103,7 @@ test.describe('Document API V2', () => {
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope use endpoint', async ({ page, request }) => {
|
||||
test('should allow authorized access to envelope use endpoint', async ({ request }) => {
|
||||
const doc = await seedTemplate({
|
||||
title: 'Team template 1',
|
||||
userId: userA.id,
|
||||
@@ -4313,5 +4313,62 @@ test.describe('Document API V2', () => {
|
||||
expect(res.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope audit logs endpoint', () => {
|
||||
test('should block unauthorized access to envelope audit logs endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/${doc.id}/audit-log`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenB}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow authorized access to envelope audit logs endpoint', async ({
|
||||
request,
|
||||
}) => {
|
||||
const doc = await seedBlankDocument(userA, teamA.id);
|
||||
|
||||
// Add a recipient which will trigger an audit log.
|
||||
await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
data: {
|
||||
envelopeId: doc.id,
|
||||
data: [
|
||||
{
|
||||
name: 'Test',
|
||||
email: 'test@example.com',
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.get(
|
||||
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/${doc.id}/audit-log`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${tokenA}` },
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
expect(Array.isArray(data.data)).toBe(true);
|
||||
expect(data.count).toEqual(1);
|
||||
expect(data.data[0].type).toEqual('RECIPIENT_CREATED');
|
||||
expect(data.currentPage).toBeGreaterThanOrEqual(1);
|
||||
expect(data.perPage).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,9 +89,8 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(3);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
expect(retrievedFields[2].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields.filter((field) => field.type === 'SIGNATURE')).toHaveLength(2);
|
||||
expect(retrievedFields.filter((field) => field.type === 'TEXT')).toHaveLength(1);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
@@ -214,10 +213,8 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(4);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
expect(retrievedFields[2].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[3].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields.filter((field) => field.type === 'SIGNATURE')).toHaveLength(3);
|
||||
expect(retrievedFields.filter((field) => field.type === 'TEXT')).toHaveLength(1);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
@@ -259,10 +256,16 @@ test.describe('AutoSave Fields Step', () => {
|
||||
});
|
||||
|
||||
expect(retrievedFields.length).toBe(2);
|
||||
expect(retrievedFields[0].type).toBe('SIGNATURE');
|
||||
expect(retrievedFields[1].type).toBe('TEXT');
|
||||
const textField = retrievedFields.find((field) => field.type === 'TEXT');
|
||||
const signatureField = retrievedFields.find((field) => field.type === 'SIGNATURE');
|
||||
|
||||
expect(signatureField).toBeDefined();
|
||||
expect(textField).toBeDefined();
|
||||
|
||||
if (!signatureField || !textField) {
|
||||
throw new Error('No signature or text field');
|
||||
}
|
||||
|
||||
const textField = retrievedFields[1];
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
if (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
@@ -83,14 +84,13 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
// delete document
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
@@ -128,14 +128,13 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
// delete document
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
@@ -169,19 +168,17 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Draft' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// delete document
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible(); // Required to reduce flakiness.
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
await expectToastTextToBeVisible(page, 'Document deleted');
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
|
||||
|
||||
@@ -203,14 +200,13 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
// Delete document.
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
@@ -239,14 +235,13 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await page
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
// Delete document.
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
@@ -292,36 +287,24 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
|
||||
});
|
||||
|
||||
// Open document action menu.
|
||||
await expect(async () => {
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
|
||||
}).toPass();
|
||||
const completedDocActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Completed' })
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, completedDocActionBtn);
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
|
||||
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
|
||||
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await expect(async () => {
|
||||
await page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn')
|
||||
.click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
|
||||
}).toPass();
|
||||
const pendingDocActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, pendingDocActionBtn);
|
||||
|
||||
// Delete document.
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
|
||||
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
|
||||
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
|
||||
export const expectTextToBeVisible = async (page: Page, text: string) => {
|
||||
@@ -7,3 +8,22 @@ export const expectTextToBeVisible = async (page: Page, text: string) => {
|
||||
export const expectTextToNotBeVisible = async (page: Page, text: string) => {
|
||||
await expect(page.getByText(text).first()).not.toBeVisible();
|
||||
};
|
||||
|
||||
export const expectToastTextToBeVisible = async (page: Page, text: string) => {
|
||||
await expect(page.locator('[role="status"]').getByText(text)).toBeVisible();
|
||||
};
|
||||
|
||||
export const openDropdownMenu = async (page: Page, dropdownButton: Locator) => {
|
||||
await page.waitForTimeout(500); // Initial timeout incase table remounts which will close the dropdown.
|
||||
await dropdownButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await dropdownButton.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await expect(page.getByRole('menuitem').first()).toBeVisible();
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||
import { expectTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
@@ -117,7 +117,9 @@ test('[TEAMS]: can pin a document folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||
|
||||
await page.reload();
|
||||
@@ -142,7 +144,9 @@ test('[TEAMS]: can unpin a document folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Unpin' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
||||
|
||||
await page.reload();
|
||||
@@ -166,7 +170,9 @@ test('[TEAMS]: can rename a document folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await page.getByLabel('Name').fill('Team Archive');
|
||||
@@ -191,7 +197,9 @@ test('[TEAMS]: document folder visibility is visible to team member', async ({ p
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toBeVisible();
|
||||
@@ -220,7 +228,9 @@ test('[TEAMS]: document folder can be moved to another document folder', async (
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button').nth(0);
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Team Clients' }).click();
|
||||
@@ -271,7 +281,9 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
|
||||
await page.getByRole('textbox').fill(`delete ${folder.name}`);
|
||||
@@ -280,12 +292,8 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
|
||||
await page.goto(`/t/${team.url}/documents`);
|
||||
|
||||
await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
|
||||
await expect(page.getByText(proposal.title)).not.toBeVisible();
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/f/${folder.id}`);
|
||||
|
||||
await expect(page.getByText(report.title)).not.toBeVisible();
|
||||
await expect(page.locator(`[data-folder-id="${reportsFolder.id}"]`)).not.toBeVisible();
|
||||
await expect(page.getByText(proposal.title)).toBeVisible();
|
||||
await expect(page.getByText(report.title)).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => {
|
||||
@@ -410,7 +418,9 @@ test('[TEAMS]: can pin a template folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||
|
||||
await page.reload();
|
||||
@@ -436,7 +446,9 @@ test('[TEAMS]: can unpin a template folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Unpin' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
||||
|
||||
await page.reload();
|
||||
@@ -462,7 +474,9 @@ test('[TEAMS]: can rename a template folder', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await page.getByLabel('Name').fill('Updated Team Template Folder');
|
||||
@@ -488,7 +502,9 @@ test('[TEAMS]: template folder visibility is not visible to team member', async
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
|
||||
@@ -519,7 +535,9 @@ test('[TEAMS]: template folder can be moved to another template folder', async (
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button').nth(0);
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Team Client Templates' }).click();
|
||||
@@ -572,7 +590,9 @@ test('[TEAMS]: template folder can be deleted', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
|
||||
await page.getByRole('textbox').fill(`delete ${folder.name}`);
|
||||
@@ -761,7 +781,9 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/`);
|
||||
|
||||
await page.getByTestId('folder-card-more-button').click();
|
||||
const folderMoreBtn1 = page.getByTestId('folder-card-more-button');
|
||||
await openDropdownMenu(page, folderMoreBtn1);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Admins only');
|
||||
@@ -781,7 +803,9 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
|
||||
|
||||
await page.goto(`/t/${team.url}/documents`);
|
||||
|
||||
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||
const folderMoreBtn2 = page.getByTestId('folder-card-more-button').nth(0);
|
||||
await openDropdownMenu(page, folderMoreBtn2);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Managers and above');
|
||||
@@ -801,7 +825,9 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
|
||||
|
||||
await page.goto(`/t/${team.url}/documents/`);
|
||||
|
||||
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||
const folderMoreBtn3 = page.getByTestId('folder-card-more-button').nth(0);
|
||||
await openDropdownMenu(page, folderMoreBtn3);
|
||||
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||
|
||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Everyone');
|
||||
@@ -966,7 +992,9 @@ test('[TEAMS]: team member can move documents to everyone folder', async ({ page
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1018,7 +1046,9 @@ test('[TEAMS]: team manager can move manager document to manager folder', async
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1071,7 +1101,9 @@ test('[TEAMS]: team manager can move manager document to everyone folder', async
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1124,7 +1156,9 @@ test('[TEAMS]: team manager can move everyone document to manager folder', async
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1177,7 +1211,9 @@ test('[TEAMS]: team admin can move admin document to admin folder', async ({ pag
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1228,7 +1264,9 @@ test('[TEAMS]: team admin can move admin document to manager folder', async ({ p
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1279,7 +1317,9 @@ test('[TEAMS]: team admin can move admin document to everyone folder', async ({
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1330,7 +1370,9 @@ test('[TEAMS]: team admin can move manager document to admin folder', async ({ p
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1381,7 +1423,9 @@ test('[TEAMS]: team admin can move manager document to manager folder', async ({
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1432,7 +1476,9 @@ test('[TEAMS]: team admin can move manager document to everyone folder', async (
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1483,7 +1529,9 @@ test('[TEAMS]: team admin can move everyone document to admin folder', async ({
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1534,7 +1582,9 @@ test('[TEAMS]: team admin can move everyone document to manager folder', async (
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1585,7 +1635,9 @@ test('[TEAMS]: team admin can move everyone document to everyone folder', async
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1630,7 +1682,9 @@ test('[TEAMS]: team owner can move admin document to admin folder', async ({ pag
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1675,7 +1729,9 @@ test('[TEAMS]: team owner can move admin document to manager folder', async ({ p
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1720,7 +1776,9 @@ test('[TEAMS]: team owner can move admin document to everyone folder', async ({
|
||||
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
|
||||
|
||||
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
|
||||
await adminDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1765,7 +1823,9 @@ test('[TEAMS]: team owner can move manager document to admin folder', async ({ p
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1810,7 +1870,9 @@ test('[TEAMS]: team owner can move manager document to manager folder', async ({
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1855,7 +1917,9 @@ test('[TEAMS]: team owner can move manager document to everyone folder', async (
|
||||
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
|
||||
|
||||
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
|
||||
await managerDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
@@ -1900,7 +1964,9 @@ test('[TEAMS]: team owner can move everyone document to admin folder', async ({
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
|
||||
@@ -1945,7 +2011,9 @@ test('[TEAMS]: team owner can move everyone document to manager folder', async (
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
||||
@@ -1990,7 +2058,9 @@ test('[TEAMS]: team owner can move everyone document to everyone folder', async
|
||||
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
|
||||
|
||||
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
|
||||
await everyoneDocRow.getByTestId('document-table-action-btn').click();
|
||||
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, docActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
||||
|
||||
@@ -6,7 +6,11 @@ import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectTextToBeVisible, expectTextToNotBeVisible } from '../fixtures/generic';
|
||||
import {
|
||||
expectTextToBeVisible,
|
||||
expectTextToNotBeVisible,
|
||||
openDropdownMenu,
|
||||
} from '../fixtures/generic';
|
||||
|
||||
test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
@@ -399,7 +403,9 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
await expect(page.getByText('Team members have been added').first()).toBeVisible();
|
||||
|
||||
// Update CUSTOM_GROUP_B
|
||||
await page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button').click();
|
||||
const updateBtn = page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button');
|
||||
await openDropdownMenu(page, updateBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Update role' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Update role' }).click();
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Team Admin' }).click();
|
||||
@@ -409,7 +415,9 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
|
||||
await page.reload();
|
||||
|
||||
// Delete CUSTOM_GROUP_B
|
||||
await page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button').click();
|
||||
const deleteBtn = page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button');
|
||||
await openDropdownMenu(page, deleteBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expectTextToBeVisible(page, 'You have successfully removed this group from the team.');
|
||||
@@ -477,7 +485,9 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
|
||||
await expect(page.getByText(user2.email)).toBeVisible();
|
||||
await expect(page.getByText(user3.email)).toBeVisible();
|
||||
|
||||
await page.getByRole('row', { name: user3.email }).getByRole('button').click();
|
||||
const inviteActionBtn = page.getByRole('row', { name: user3.email }).getByRole('button');
|
||||
await openDropdownMenu(page, inviteActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
await expect(page.getByText('Invitation has been deleted').first()).toBeVisible();
|
||||
await expect(page.getByText(user3.email)).not.toBeVisible();
|
||||
@@ -508,7 +518,9 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
|
||||
await expect(page.getByText(user.email)).toBeVisible();
|
||||
await expect(page.getByText(user2.email)).toBeVisible();
|
||||
|
||||
await page.getByRole('row', { name: user2.email }).getByRole('button').click();
|
||||
const memberActionBtn = page.getByRole('row', { name: user2.email }).getByRole('button');
|
||||
await openDropdownMenu(page, memberActionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
await expect(page.getByText('You have successfully removed').first()).toBeVisible();
|
||||
@@ -522,7 +534,9 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
|
||||
await expect(page.getByText(user.email)).toBeVisible();
|
||||
await expect(page.getByText(user2.email)).toBeVisible();
|
||||
|
||||
await page.getByRole('row', { name: user2.email }).getByRole('button').click();
|
||||
const orgMemberBtn = page.getByRole('row', { name: user2.email }).getByRole('button');
|
||||
await openDropdownMenu(page, orgMemberBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(page.getByText('You have successfully removed this user').first()).toBeVisible();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
@@ -44,6 +46,9 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
.fill('public-direct-template-description');
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
|
||||
// Wait for toast
|
||||
await expectToastTextToBeVisible(page, 'Template has been updated');
|
||||
|
||||
// Check that public profile is disabled.
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
||||
await expect(page.locator('body')).toContainText('404 Profile not found');
|
||||
@@ -51,7 +56,21 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
|
||||
// Go back to public profile page.
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
|
||||
await page.getByRole('switch').click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Expect profile to be enabled via db.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const profile = await prisma.teamProfile.findFirst({
|
||||
where: { teamId: team.id },
|
||||
});
|
||||
return profile?.enabled;
|
||||
},
|
||||
{
|
||||
timeout: 1000,
|
||||
},
|
||||
)
|
||||
.toBeTruthy();
|
||||
|
||||
// Assert values.
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
|
||||
|
||||
@@ -11,7 +11,11 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||
import {
|
||||
expectTextToBeVisible,
|
||||
expectToastTextToBeVisible,
|
||||
openDropdownMenu,
|
||||
} from '../fixtures/generic';
|
||||
|
||||
test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
|
||||
@@ -239,21 +243,15 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
await page.getByRole('menuitem').filter({ hasText: 'Resend' }).click();
|
||||
const actionBtn = page.getByTestId('document-table-action-btn').first();
|
||||
await expect(actionBtn).toBeAttached();
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Resend' }).click();
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('status').filter({ hasText: 'Document re-sent' }).first(),
|
||||
).toBeVisible();
|
||||
await expectToastTextToBeVisible(page, 'Document re-sent');
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
@@ -265,14 +263,12 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
const actionBtn = page.getByTestId('document-table-action-btn').first();
|
||||
await expect(actionBtn).toBeVisible({
|
||||
timeout: 500,
|
||||
});
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
@@ -309,14 +305,12 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
const actionBtn = page.getByTestId('document-table-action-btn').first();
|
||||
await expect(actionBtn).toBeVisible({
|
||||
timeout: 500,
|
||||
});
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click({ force: true });
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click({ force: true });
|
||||
@@ -354,14 +348,12 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
|
||||
});
|
||||
|
||||
await expect(async () => {
|
||||
await page.getByTestId('document-table-action-btn').first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
}).toPass();
|
||||
|
||||
const actionBtn = page.getByTestId('document-table-action-btn').first();
|
||||
await expect(actionBtn).toBeVisible({
|
||||
timeout: 500,
|
||||
});
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click({ force: true });
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click({ force: true });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test('[TEAMS]: send team email request', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
@@ -54,8 +55,13 @@ test('[TEAMS]: delete team email', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click();
|
||||
const settingsBtn = page
|
||||
.locator('section div')
|
||||
.filter({ hasText: 'Team email' })
|
||||
.getByRole('button');
|
||||
await openDropdownMenu(page, settingsBtn);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
|
||||
@@ -296,7 +296,13 @@ test.describe('AutoSave Fields Step', () => {
|
||||
['SIGNATURE', 'TEXT'].toSorted(),
|
||||
);
|
||||
|
||||
const textField = fields[1];
|
||||
const textField = fields.find((field) => field.type === 'TEXT');
|
||||
expect(textField).toBeDefined();
|
||||
|
||||
if (!textField) {
|
||||
throw new Error('No text field');
|
||||
}
|
||||
|
||||
expect(textField.fieldMeta).toBeDefined();
|
||||
|
||||
if (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test('[TEMPLATES]: view templates', async ({ page }) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
@@ -71,13 +72,14 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||
});
|
||||
|
||||
for (const template of ['Team template 1', 'Team template 2']) {
|
||||
await page
|
||||
const actionBtn = page
|
||||
.getByRole('row', { name: template })
|
||||
.getByRole('cell', { name: 'Use Template' })
|
||||
.getByRole('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
.nth(1);
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||
@@ -110,7 +112,9 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
|
||||
});
|
||||
|
||||
// Duplicate team template.
|
||||
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||
const actionBtn = page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1);
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expect(page.getByText('Template duplicated').first()).toBeVisible();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectTextToBeVisible } from '../fixtures/generic';
|
||||
import { expectTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
/**
|
||||
* Helper function to seed a webhook directly in the database for testing.
|
||||
@@ -147,9 +147,11 @@ test('[WEBHOOKS]: delete webhook', async ({ page }) => {
|
||||
|
||||
// Find the row with the webhook and click the action dropdown
|
||||
const webhookRow = page.locator('tr', { hasText: webhookUrl });
|
||||
await webhookRow.getByTestId('webhook-table-action-btn').click();
|
||||
const actionBtn = webhookRow.getByTestId('webhook-table-action-btn');
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
|
||||
// Click Delete menu item
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
|
||||
// Fill in confirmation field
|
||||
@@ -196,9 +198,11 @@ test('[WEBHOOKS]: update webhook', async ({ page }) => {
|
||||
|
||||
// Find the row with the webhook and click the action dropdown
|
||||
const webhookRow = page.locator('tr', { hasText: originalWebhookUrl });
|
||||
await webhookRow.getByTestId('webhook-table-action-btn').click();
|
||||
const actionBtn = webhookRow.getByTestId('webhook-table-action-btn');
|
||||
await openDropdownMenu(page, actionBtn);
|
||||
|
||||
// Click Edit menu item
|
||||
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
// Wait for dialog to open
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
|
||||
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
|
||||
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
|
||||
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test NEXT_PRIVATE_LOGGER_FILE_PATH=./logs.json start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
function calculateWorkers() {
|
||||
const total = os.cpus().length;
|
||||
|
||||
// Reserve 2 cores for the system
|
||||
const usable = Math.max(total - 2, 1);
|
||||
|
||||
// 1 worker per 2 cores, minimum 1
|
||||
const workers = Math.max(Math.floor(usable / 2), 1);
|
||||
|
||||
// Max 6 workers
|
||||
return Math.min(workers, 6);
|
||||
}
|
||||
|
||||
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||
|
||||
ENV_FILES.forEach((file) => {
|
||||
@@ -15,9 +29,8 @@ ENV_FILES.forEach((file) => {
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: false,
|
||||
workers: 2,
|
||||
fullyParallel: true,
|
||||
workers: 10, // See Projects where 10 is utilized for API tests. We're not running 10 workers for UI tests.
|
||||
maxFailures: process.env.CI ? 1 : undefined,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
@@ -31,25 +44,54 @@ export default defineConfig({
|
||||
baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on',
|
||||
|
||||
video: 'on-first-retry',
|
||||
trace: 'retain-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
|
||||
/* Add explicit timeouts for actions */
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
|
||||
contextOptions: {
|
||||
reducedMotion: 'reduce',
|
||||
},
|
||||
|
||||
/* Disable animations via cookie for more stable tests */
|
||||
storageState: {
|
||||
cookies: [
|
||||
{
|
||||
name: '__disable_animations',
|
||||
value: 'true',
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
expires: -1,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: 'Lax' as const,
|
||||
},
|
||||
],
|
||||
origins: [],
|
||||
},
|
||||
},
|
||||
|
||||
timeout: 60_000,
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
// API Tests e2e/api/**/*.spec.ts
|
||||
{
|
||||
name: 'chromium',
|
||||
name: 'api',
|
||||
testMatch: /e2e\/api\/.*\.spec\.ts/,
|
||||
workers: 10, // Limited by DB connections before it gets flakey.
|
||||
},
|
||||
// Run UI Tests
|
||||
{
|
||||
name: 'ui',
|
||||
testMatch: /e2e\/(?!api\/).*\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1200 },
|
||||
},
|
||||
workers: calculateWorkers(),
|
||||
},
|
||||
|
||||
// {
|
||||
|
||||
@@ -32,17 +32,17 @@
|
||||
"@react-email/img": "0.0.11",
|
||||
"@react-email/link": "0.0.12",
|
||||
"@react-email/preview": "0.0.13",
|
||||
"@react-email/render": "0.0.17",
|
||||
"@react-email/render": "2.0.0",
|
||||
"@react-email/row": "0.0.12",
|
||||
"@react-email/section": "0.0.16",
|
||||
"@react-email/tailwind": "^2.0.1",
|
||||
"@react-email/text": "0.1.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"react-email": "^5.0.4",
|
||||
"react-email": "^5.0.6",
|
||||
"resend": "^6.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/nodemailer": "^6.4.21"
|
||||
"@types/nodemailer": "^7.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export type RenderOptions = ReactEmail.Options & {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const colors = (config.theme?.extend?.colors || {}) as Record<string, string>;
|
||||
|
||||
export const render = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
export const render = async (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return ReactEmail.render(
|
||||
@@ -36,7 +36,7 @@ export const render = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const renderWithI18N = (element: React.ReactNode, options?: RenderOptions) => {
|
||||
export const renderWithI18N = async (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, i18n, ...otherOptions } = options ?? {};
|
||||
|
||||
if (!i18n) {
|
||||
@@ -62,24 +62,3 @@ export const renderWithI18N = (element: React.ReactNode, options?: RenderOptions
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
export const renderAsync = async (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
return await ReactEmail.renderAsync(
|
||||
<BrandingProvider branding={branding}>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{element}
|
||||
</Tailwind>
|
||||
</BrandingProvider>,
|
||||
otherOptions,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/cli
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
@@ -77,7 +78,8 @@ export const run = async ({
|
||||
const recipientsToNotify = envelope.recipients.filter(
|
||||
(recipient) =>
|
||||
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
|
||||
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||
recipient.signingStatus !== SigningStatus.REJECTED &&
|
||||
isRecipientEmailValidForSending(recipient),
|
||||
);
|
||||
|
||||
await io.runTask('send-cancellation-emails', async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
|
||||
@@ -79,8 +80,8 @@ export const run = async ({
|
||||
|
||||
const recipientReference = recipientName || recipientEmail;
|
||||
|
||||
// Don't send notification if the owner is the one who signed
|
||||
if (owner.email === recipientEmail) {
|
||||
// Don't send notification if the owner is the one who signed.
|
||||
if (owner.email === recipientEmail || !isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
|
||||
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
|
||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
@@ -85,36 +86,38 @@ export const run = async ({
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
// Send confirmation email to the recipient who rejected
|
||||
await io.runTask('send-rejection-confirmation-email', async () => {
|
||||
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
|
||||
recipientName: recipient.name,
|
||||
documentName: envelope.title,
|
||||
documentOwnerName: envelope.user.name || envelope.user.email,
|
||||
reason: recipient.rejectionReason || '',
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
if (isRecipientEmailValidForSending(recipient)) {
|
||||
await io.runTask('send-rejection-confirmation-email', async () => {
|
||||
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
|
||||
recipientName: recipient.name,
|
||||
documentName: envelope.title,
|
||||
documentOwnerName: envelope.user.name || envelope.user.email,
|
||||
reason: recipient.rejectionReason || '',
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(recipientTemplate, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(recipientTemplate, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
|
||||
html,
|
||||
text,
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Send notification email to document owner
|
||||
await io.runTask('send-owner-notification-email', async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import DocumentInviteEmailTemplate from '@documenso/email/templates/document-invite';
|
||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
@@ -177,31 +178,33 @@ export const run = async ({
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
});
|
||||
|
||||
await io.runTask('send-signing-email', async () => {
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
if (isRecipientEmailValidForSending(recipient)) {
|
||||
await io.runTask('send-signing-email', async () => {
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: emailLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: renderCustomEmailTemplate(
|
||||
documentMeta?.subject || emailSubject,
|
||||
customEmailTemplate,
|
||||
),
|
||||
html,
|
||||
text,
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: recipient.name,
|
||||
address: recipient.email,
|
||||
},
|
||||
from: senderEmail,
|
||||
replyTo: replyToEmail,
|
||||
subject: renderCustomEmailTemplate(
|
||||
documentMeta?.subject || emailSubject,
|
||||
customEmailTemplate,
|
||||
),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await io.runTask('update-recipient', async () => {
|
||||
await prisma.recipient.update({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa';
|
||||
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
@@ -69,6 +70,12 @@ export const send2FATokenEmail = async ({ token, envelopeId }: Send2FATokenEmail
|
||||
});
|
||||
}
|
||||
|
||||
if (!isRecipientEmailValidForSending(recipient)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Recipient is missing email address',
|
||||
});
|
||||
}
|
||||
|
||||
const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({
|
||||
envelopeId,
|
||||
email: recipient.email,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
@@ -64,14 +65,18 @@ export const adminSuperDeleteDocument = async ({
|
||||
envelope.documentMeta,
|
||||
).documentDeleted;
|
||||
|
||||
const recipientsToNotify = envelope.recipients.filter((recipient) =>
|
||||
isRecipientEmailValidForSending(recipient),
|
||||
);
|
||||
|
||||
// if the document is pending, send cancellation emails to all recipients
|
||||
if (
|
||||
status === DocumentStatus.PENDING &&
|
||||
envelope.recipients.length > 0 &&
|
||||
recipientsToNotify.length > 0 &&
|
||||
isDocumentDeletedEmailEnabled
|
||||
) {
|
||||
await Promise.all(
|
||||
envelope.recipients.map(async (recipient) => {
|
||||
recipientsToNotify.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
DocumentSigningOrder,
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
@@ -43,6 +44,14 @@ export type CompleteDocumentWithTokenOptions = {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
/**
|
||||
* Override the recipient information. This will only work if the recipient
|
||||
* does not have a name or email set.
|
||||
*/
|
||||
recipientOverride?: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const completeDocumentWithToken = async ({
|
||||
@@ -52,6 +61,7 @@ export const completeDocumentWithToken = async ({
|
||||
accessAuthOptions,
|
||||
requestMetadata,
|
||||
nextSigner,
|
||||
recipientOverride,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -116,6 +126,35 @@ export const completeDocumentWithToken = async ({
|
||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||
}
|
||||
|
||||
let recipientName = recipient.name;
|
||||
let recipientEmail = recipient.email;
|
||||
|
||||
// Only trim the name if it's been derived.
|
||||
if (!recipientName) {
|
||||
recipientName = (
|
||||
recipientOverride?.name ||
|
||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
''
|
||||
).trim();
|
||||
}
|
||||
|
||||
// Only trim the email if it's been derived.
|
||||
if (!recipient.email) {
|
||||
recipientEmail = (
|
||||
recipientOverride?.email ||
|
||||
fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
|
||||
''
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
if (!recipientEmail) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Recipient email is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Check ACCESS AUTH 2FA validation during document completion
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
@@ -129,6 +168,12 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (!recipient.email.trim()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient ${recipient.id} requires an email because they have auth requirements.`,
|
||||
});
|
||||
}
|
||||
|
||||
const isValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS_2FA',
|
||||
documentAuthOptions: envelope.authOptions,
|
||||
@@ -176,9 +221,43 @@ export const completeDocumentWithToken = async ({
|
||||
data: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: new Date(),
|
||||
name: recipientName,
|
||||
email: recipientEmail,
|
||||
},
|
||||
});
|
||||
|
||||
if (recipientEmail !== recipient.email || recipientName !== recipient.name) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
name: recipientName,
|
||||
email: recipientEmail,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
changes: [
|
||||
{
|
||||
type: RECIPIENT_DIFF_TYPE.NAME,
|
||||
from: recipient.name,
|
||||
to: recipientName,
|
||||
},
|
||||
{
|
||||
type: RECIPIENT_DIFF_TYPE.EMAIL,
|
||||
from: recipient.email,
|
||||
to: recipientEmail,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const authOptions = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
@@ -189,13 +268,13 @@ export const completeDocumentWithToken = async ({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
name: recipientName,
|
||||
email: recipientEmail,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientEmail: recipientEmail,
|
||||
recipientName: recipientName,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
actionAuth: authOptions.derivedRecipientActionAuth,
|
||||
@@ -247,8 +326,8 @@ export const completeDocumentWithToken = async ({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
name: recipientName,
|
||||
email: recipientEmail,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { ApiRequestMetadata } from '../../universal/extract-request-metadat
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
@@ -209,7 +210,7 @@ const handleDocumentOwnerDelete = async ({
|
||||
// Send cancellation emails to recipients.
|
||||
await Promise.all(
|
||||
envelope.recipients.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT) {
|
||||
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
@@ -118,7 +119,7 @@ export const resendDocument = async ({
|
||||
|
||||
await Promise.all(
|
||||
recipientsToRemind.map(async (recipient) => {
|
||||
if (recipient.role === RecipientRole.CC) {
|
||||
if (recipient.role === RecipientRole.CC || !isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { formatDocumentsPath } from '../../utils/teams';
|
||||
@@ -176,8 +177,12 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
|
||||
return;
|
||||
}
|
||||
|
||||
const recipientsToNotify = envelope.recipients.filter((recipient) =>
|
||||
isRecipientEmailValidForSending(recipient),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
envelope.recipients.map(async (recipient) => {
|
||||
recipientsToNotify.map(async (recipient) => {
|
||||
const customEmailTemplate = {
|
||||
'signer.name': recipient.name,
|
||||
'signer.email': recipient.email,
|
||||
|
||||
@@ -35,8 +35,10 @@ import {
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { isDocumentCompleted } from '../../utils/document';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@@ -128,6 +130,24 @@ export const sendDocument = async ({
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that recipients with auth requirements have a valid email.
|
||||
envelope.recipients.forEach((recipient) => {
|
||||
const auth = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
if (
|
||||
recipient.role !== RecipientRole.CC &&
|
||||
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) &&
|
||||
!isRecipientEmailValidForSending(recipient)
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Recipient ${recipient.id} requires an email because they have auth requirements.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Commented out server side checks for minimum 1 signature per signer now since we need to
|
||||
// decide if we want to enforce this for API & templates.
|
||||
// const fields = await getFieldsForDocument({
|
||||
|
||||
@@ -12,6 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
|
||||
import { isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
|
||||
@@ -69,6 +70,11 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti
|
||||
|
||||
const { email, name } = recipient;
|
||||
|
||||
// Skip sending email if recipient has no email address
|
||||
if (!isRecipientEmailValidForSending(recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentPendingEmailTemplate, {
|
||||
|
||||
@@ -12,11 +12,13 @@ export type CreateEmbeddingPresignTokenOptions = {
|
||||
* In development mode, can be set to 0 to create a token that expires immediately (for testing)
|
||||
*/
|
||||
expiresIn?: number;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
export const createEmbeddingPresignToken = async ({
|
||||
apiToken,
|
||||
expiresIn,
|
||||
scope,
|
||||
}: CreateEmbeddingPresignTokenOptions) => {
|
||||
try {
|
||||
// Validate the API token
|
||||
@@ -40,6 +42,7 @@ export const createEmbeddingPresignToken = async ({
|
||||
const token = await new SignJWT({
|
||||
aud: String(validatedToken.teamId ?? validatedToken.userId),
|
||||
sub: String(validatedToken.id),
|
||||
scope,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt(now.toJSDate())
|
||||
|
||||
@@ -7,10 +7,12 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export type VerifyEmbeddingPresignTokenOptions = {
|
||||
token: string;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
export const verifyEmbeddingPresignToken = async ({
|
||||
token,
|
||||
scope,
|
||||
}: VerifyEmbeddingPresignTokenOptions) => {
|
||||
// First decode the JWT to get the claims without verification
|
||||
let decodedToken: JWTPayload;
|
||||
@@ -81,6 +83,12 @@ export const verifyEmbeddingPresignToken = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (decodedToken.scope && scope && decodedToken.scope !== scope) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Presign token scope not matched',
|
||||
});
|
||||
}
|
||||
|
||||
// Now verify the token with the actual secret
|
||||
const secret = new TextEncoder().encode(apiToken.token);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { TextAlignment, rgb, setFontAndSize } from '@cantoo/pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
|
||||
import { getPageSize } from './get-page-size';
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,7 @@ export async function addRejectionStampToPdf(
|
||||
const pages = pdfDoc.getPages();
|
||||
pdfDoc.registerFontkit(fontkit);
|
||||
|
||||
const fontBytes = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
|
||||
const fontBytes = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
|
||||
async (res) => res.arrayBuffer(),
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '@cantoo/pdf-lib';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
export const removeOptionalContentGroups = (document: PDFDocument) => {
|
||||
const context = document.context;
|
||||
@@ -29,7 +29,7 @@ export const flattenForm = async (document: PDFDocument) => {
|
||||
|
||||
const form = document.getForm();
|
||||
|
||||
const fontNoto = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
|
||||
const fontNoto = await fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
|
||||
async (res) => res.arrayBuffer(),
|
||||
);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDateFieldMeta,
|
||||
@@ -37,8 +37,12 @@ import { getPageSize } from './get-page-size';
|
||||
|
||||
export const insertFieldInPDFV1 = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const [fontCaveat, fontNoto] = await Promise.all([
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
]);
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
||||
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { NEXT_PRIVATE_INTERNAL_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
ZCheckboxFieldMeta,
|
||||
ZDateFieldMeta,
|
||||
@@ -30,8 +30,12 @@ import { getPageSize } from './get-page-size';
|
||||
|
||||
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
||||
const [fontCaveat, fontNoto] = await Promise.all([
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
fetch(`${NEXT_PRIVATE_INTERNAL_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
]);
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { canRecipientBeModified } from '../../utils/recipients';
|
||||
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
@@ -142,7 +142,8 @@ export const deleteEnvelopeRecipient = async ({
|
||||
if (
|
||||
recipientToDelete.sendStatus === SendStatus.SENT &&
|
||||
isRecipientRemovedEmailEnabled &&
|
||||
envelope.type === EnvelopeType.DOCUMENT
|
||||
envelope.type === EnvelopeType.DOCUMENT &&
|
||||
isRecipientEmailValidForSending(recipientToDelete)
|
||||
) {
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
|
||||
import { canRecipientBeModified } from '../../utils/recipients';
|
||||
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
@@ -294,10 +294,15 @@ export const setDocumentRecipients = async ({
|
||||
envelope.documentMeta,
|
||||
).recipientRemoved;
|
||||
|
||||
// Send emails to deleted recipients.
|
||||
// Send emails to deleted recipients who have emails.
|
||||
await Promise.all(
|
||||
removedRecipients.map(async (recipient) => {
|
||||
if (recipient.sendStatus !== SendStatus.SENT || !isRecipientRemovedEmailEnabled) {
|
||||
if (
|
||||
recipient.sendStatus !== SendStatus.SENT ||
|
||||
recipient.role === RecipientRole.CC ||
|
||||
!isRecipientRemovedEmailEnabled ||
|
||||
!isRecipientEmailValidForSending(recipient)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { RecipientSchema } from '@documenso/prisma/generated/zod/modelSchema/RecipientSchema';
|
||||
@@ -110,3 +111,13 @@ export const ZEnvelopeRecipientManySchema = ZRecipientManySchema.omit({
|
||||
documentId: true,
|
||||
templateId: true,
|
||||
});
|
||||
|
||||
export const ZRecipientEmailSchema = z.union([
|
||||
z.literal(''),
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.email({ message: msg`Invalid email`.id })
|
||||
.max(254),
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Envelope } from '@prisma/client';
|
||||
import { type Field, type Recipient, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import { extractLegacyIds } from '../universal/id';
|
||||
@@ -58,3 +59,7 @@ export const mapRecipientToLegacyRecipient = (
|
||||
...legacyId,
|
||||
};
|
||||
};
|
||||
|
||||
export const isRecipientEmailValidForSending = (recipient: Pick<Recipient, 'email'>) => {
|
||||
return z.string().email().safeParse(recipient.email).success;
|
||||
};
|
||||
|
||||
@@ -587,6 +587,9 @@ export const seedPendingDocumentWithFullFields = async ({
|
||||
where: {
|
||||
envelopeId: document.id,
|
||||
},
|
||||
orderBy: {
|
||||
signingOrder: 'asc',
|
||||
},
|
||||
include: {
|
||||
fields: true,
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ export const createEmbeddingPresignTokenRoute = procedure
|
||||
});
|
||||
}
|
||||
|
||||
const { expiresIn } = input;
|
||||
const { expiresIn, scope } = input;
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
const token = await getApiTokenByToken({ token: apiToken });
|
||||
@@ -54,6 +54,7 @@ export const createEmbeddingPresignTokenRoute = procedure
|
||||
const presignToken = await createEmbeddingPresignToken({
|
||||
apiToken,
|
||||
expiresIn,
|
||||
scope,
|
||||
});
|
||||
|
||||
return { ...presignToken };
|
||||
|
||||
@@ -21,6 +21,10 @@ export const ZCreateEmbeddingPresignTokenRequestSchema = z.object({
|
||||
.optional()
|
||||
.default(60)
|
||||
.describe('Expiration time in minutes (default: 60, max: 10,080)'),
|
||||
scope: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Resource restriction. Example: documentId:1, templateId:2'),
|
||||
});
|
||||
|
||||
export const ZCreateEmbeddingPresignTokenResponseSchema = z.object({
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ZFieldWidthSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
|
||||
import { ZDocumentTitleSchema } from '../document-router/schema';
|
||||
|
||||
@@ -30,7 +31,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
|
||||
documentDataId: z.string(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
email: z.union([z.string().length(0), z.string().email()]),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
|
||||
@@ -34,7 +34,10 @@ export const updateEmbeddingDocumentRoute = procedure
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
|
||||
const apiToken = await verifyEmbeddingPresignToken({
|
||||
token: presignToken,
|
||||
scope: `documentId:${input.documentId}`,
|
||||
});
|
||||
|
||||
const { documentId, title, externalId, recipients, meta } = input;
|
||||
|
||||
|
||||
@@ -33,7 +33,10 @@ export const updateEmbeddingTemplateRoute = procedure
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
|
||||
const apiToken = await verifyEmbeddingPresignToken({
|
||||
token: presignToken,
|
||||
scope: `templateId:${input.templateId}`,
|
||||
});
|
||||
|
||||
const { templateId, title, externalId, recipients, meta } = input;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
@@ -21,22 +21,11 @@ import {
|
||||
ZFieldPageYSchema,
|
||||
ZFieldWidthSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZFieldAndMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
|
||||
import { ZDocumentTitleSchema } from '../document-router/schema';
|
||||
|
||||
const ZFieldSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
width: ZFieldWidthSchema,
|
||||
height: ZFieldHeightSchema,
|
||||
fieldMeta: ZFieldMetaSchema.optional(),
|
||||
envelopeItemId: z.string(),
|
||||
});
|
||||
|
||||
export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
|
||||
templateId: z.number(),
|
||||
title: ZDocumentTitleSchema.optional(),
|
||||
@@ -44,7 +33,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
email: z.union([z.string().length(0), z.string().email()]),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
|
||||
@@ -17,10 +17,11 @@ export const verifyEmbeddingPresignTokenRoute = procedure
|
||||
.output(ZVerifyEmbeddingPresignTokenResponseSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const { token } = input;
|
||||
const { token, scope } = input;
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({
|
||||
token,
|
||||
scope,
|
||||
}).catch(() => null);
|
||||
|
||||
return { success: !!apiToken };
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ZVerifyEmbeddingPresignTokenRequestSchema = z.object({
|
||||
.string()
|
||||
.min(1, { message: 'Token is required' })
|
||||
.describe('The presign token to verify'),
|
||||
scope: z.string().optional().describe('The scope to verify'),
|
||||
});
|
||||
|
||||
export const ZVerifyEmbeddingPresignTokenResponseSchema = z.object({
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
ZDocumentTitleSchema,
|
||||
ZDocumentVisibilitySchema,
|
||||
} from '../document-router/schema';
|
||||
import { ZCreateRecipientSchema } from '../recipient-router/schema';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
import { ZCreateEnvelopeRecipientSchema } from './envelope-recipients/create-envelope-recipients.types';
|
||||
|
||||
export const createEnvelopeMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
@@ -54,7 +54,7 @@ export const ZCreateEnvelopePayloadSchema = z.object({
|
||||
.optional(),
|
||||
recipients: z
|
||||
.array(
|
||||
ZCreateRecipientSchema.extend({
|
||||
ZCreateEnvelopeRecipientSchema.extend({
|
||||
fields: ZEnvelopeFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
identifier: z
|
||||
|
||||
+19
-3
@@ -1,8 +1,15 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZEnvelopeRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||
import {
|
||||
ZRecipientAccessAuthTypesSchema,
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
ZEnvelopeRecipientLiteSchema,
|
||||
ZRecipientEmailSchema,
|
||||
} from '@documenso/lib/types/recipient';
|
||||
|
||||
import { ZCreateRecipientSchema } from '../../recipient-router/schema';
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const createEnvelopeRecipientsMeta: TrpcRouteMeta = {
|
||||
@@ -15,9 +22,18 @@ export const createEnvelopeRecipientsMeta: TrpcRouteMeta = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ZCreateEnvelopeRecipientSchema = z.object({
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string().max(255),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
|
||||
});
|
||||
|
||||
export const ZCreateEnvelopeRecipientsRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
data: ZCreateRecipientSchema.array(),
|
||||
data: ZCreateEnvelopeRecipientSchema.array(),
|
||||
});
|
||||
|
||||
export const ZCreateEnvelopeRecipientsResponseSchema = z.object({
|
||||
|
||||
+17
-3
@@ -1,8 +1,12 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||
import {
|
||||
ZRecipientAccessAuthTypesSchema,
|
||||
ZRecipientActionAuthTypesSchema,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||
|
||||
import { ZUpdateRecipientSchema } from '../../recipient-router/schema';
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const updateEnvelopeRecipientsMeta: TrpcRouteMeta = {
|
||||
@@ -15,9 +19,19 @@ export const updateEnvelopeRecipientsMeta: TrpcRouteMeta = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ZUpdateEnvelopeRecipientSchema = z.object({
|
||||
id: z.number().describe('The ID of the recipient to update.'),
|
||||
email: ZRecipientEmailSchema.optional(),
|
||||
name: z.string().max(255).optional(),
|
||||
role: z.nativeEnum(RecipientRole).optional(),
|
||||
signingOrder: z.number().optional(),
|
||||
accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(),
|
||||
actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(),
|
||||
});
|
||||
|
||||
export const ZUpdateEnvelopeRecipientsRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
data: ZUpdateRecipientSchema.array(),
|
||||
data: ZUpdateEnvelopeRecipientSchema.array(),
|
||||
});
|
||||
|
||||
export const ZUpdateEnvelopeRecipientsResponseSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import type { FindResultResponse } from '@documenso/lib/types/search-params';
|
||||
import { parseDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZFindEnvelopeAuditLogsRequestSchema,
|
||||
ZFindEnvelopeAuditLogsResponseSchema,
|
||||
findEnvelopeAuditLogsMeta,
|
||||
} from './find-envelope-audit-logs.types';
|
||||
|
||||
export const findEnvelopeAuditLogsRoute = authenticatedProcedure
|
||||
.meta(findEnvelopeAuditLogsMeta)
|
||||
.input(ZFindEnvelopeAuditLogsRequestSchema)
|
||||
.output(ZFindEnvelopeAuditLogsResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const {
|
||||
envelopeId,
|
||||
page = 1,
|
||||
perPage = 50,
|
||||
orderByColumn = 'createdAt',
|
||||
orderByDirection = 'desc',
|
||||
} = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
type: null,
|
||||
userId: ctx.user.id,
|
||||
teamId: ctx.teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: envelopeWhereInput,
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Only documents have audit logs.
|
||||
if (envelope.type !== EnvelopeType.DOCUMENT) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Templates do not have audit logs.',
|
||||
});
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.documentAuditLog.findMany({
|
||||
where: { envelopeId: envelope.id },
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
}),
|
||||
prisma.documentAuditLog.count({
|
||||
where: { envelopeId: envelope.id },
|
||||
}),
|
||||
]);
|
||||
|
||||
const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog));
|
||||
|
||||
return {
|
||||
data: parsedData,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof parsedData>;
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentAuditLogSchema } from '@documenso/lib/types/document-audit-logs';
|
||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const findEnvelopeAuditLogsMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/envelope/{envelopeId}/audit-log',
|
||||
summary: 'Get envelope audit logs',
|
||||
description: 'Find audit logs based on a search criteria',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZFindEnvelopeAuditLogsRequestSchema = ZFindSearchParamsSchema.omit({
|
||||
query: true,
|
||||
}).extend({
|
||||
envelopeId: z.string().describe('Envelope ID'),
|
||||
orderByColumn: z.enum(['createdAt']).optional(),
|
||||
orderByDirection: z.enum(['asc', 'desc']).optional(),
|
||||
});
|
||||
|
||||
export const ZFindEnvelopeAuditLogsResponseSchema = ZFindResultResponse.extend({
|
||||
data: ZDocumentAuditLogSchema.array(),
|
||||
});
|
||||
|
||||
export type TFindEnvelopeAuditLogsRequest = z.infer<typeof ZFindEnvelopeAuditLogsRequestSchema>;
|
||||
export type TFindEnvelopeAuditLogsResponse = z.infer<typeof ZFindEnvelopeAuditLogsResponseSchema>;
|
||||
@@ -18,6 +18,7 @@ import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-enve
|
||||
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
|
||||
import { getEnvelopeRecipientRoute } from './envelope-recipients/get-envelope-recipient';
|
||||
import { updateEnvelopeRecipientsRoute } from './envelope-recipients/update-envelope-recipients';
|
||||
import { findEnvelopeAuditLogsRoute } from './find-envelope-audit-logs';
|
||||
import { getEnvelopeRoute } from './get-envelope';
|
||||
import { getEnvelopeItemsRoute } from './get-envelope-items';
|
||||
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
|
||||
@@ -65,6 +66,9 @@ export const envelopeRouter = router({
|
||||
set: setEnvelopeFieldsRoute,
|
||||
sign: signEnvelopeFieldRoute,
|
||||
},
|
||||
auditLog: {
|
||||
find: findEnvelopeAuditLogsRoute,
|
||||
},
|
||||
get: getEnvelopeRoute,
|
||||
create: createEnvelopeRoute,
|
||||
use: useEnvelopeRoute,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EnvelopeType, RecipientRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
|
||||
import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||
import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
|
||||
|
||||
export const ZSetEnvelopeRecipientsRequestSchema = z.object({
|
||||
envelopeId: z.string(),
|
||||
@@ -10,7 +10,7 @@ export const ZSetEnvelopeRecipientsRequestSchema = z.object({
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
email: z.string().toLowerCase().email().min(1).max(254),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string().max(255),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
signingOrder: z.number().optional(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
|
||||
import { zodFormData } from '../../utils/zod-form-data';
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
@@ -40,7 +41,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number().describe('The ID of the recipient in the template.'),
|
||||
email: z.string().email().max(254),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string().max(255).optional(),
|
||||
signingOrder: z.number().optional(),
|
||||
}),
|
||||
|
||||
@@ -561,7 +561,7 @@ export const recipientRouter = router({
|
||||
completeDocumentWithToken: procedure
|
||||
.input(ZCompleteDocumentWithTokenMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token, documentId, accessAuthOptions, nextSigner } = input;
|
||||
const { token, documentId, accessAuthOptions, nextSigner, recipientOverride } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
@@ -577,6 +577,7 @@ export const recipientRouter = router({
|
||||
},
|
||||
accessAuthOptions,
|
||||
nextSigner,
|
||||
recipientOverride,
|
||||
userId: ctx.user?.id,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
});
|
||||
|
||||
@@ -171,6 +171,12 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
})
|
||||
.optional(),
|
||||
recipientOverride: z
|
||||
.object({
|
||||
email: z.string().trim().toLowerCase().email().max(254).optional(),
|
||||
name: z.string().max(255).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '@documenso/lib/types/document-meta';
|
||||
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||
import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import {
|
||||
ZTemplateLiteSchema,
|
||||
@@ -102,7 +103,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number().describe('The ID of the recipient in the template.'),
|
||||
email: z.string().email().max(254),
|
||||
email: ZRecipientEmailSchema,
|
||||
name: z.string().max(255).optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user