diff --git a/.agents/plans/bold-emerald-earth-pdf-placeholder-field-positioning.md b/.agents/plans/bold-emerald-earth-pdf-placeholder-field-positioning.md new file mode 100644 index 000000000..580dbdc14 --- /dev/null +++ b/.agents/plans/bold-emerald-earth-pdf-placeholder-field-positioning.md @@ -0,0 +1,161 @@ +--- +date: 2026-01-28 +title: Pdf Placeholder Field Positioning +--- + +## Overview + +This feature enables automatic field placement in PDFs using placeholder text, eliminating the need for manual coordinate-based positioning. It supports two complementary workflows: + +1. **Automatic detection on upload** - PDFs containing structured placeholders like `{{signature, r1}}` have fields created automatically when uploaded +2. **API placeholder positioning** - Developers can reference any text in a PDF to position fields instead of calculating coordinates + +## Goals + +- Allow users to prepare documents in Word/Google Docs with placeholders that become signature fields +- Reduce friction for document preparation workflows +- Provide API developers with a simpler alternative to coordinate-based field positioning +- Support documents with repeated placeholders (e.g., initials on every page) + +## Placeholder Format (Automatic Detection) + +``` +{{FIELD_TYPE, RECIPIENT, option1=value1, option2=value2}} +``` + +### Components + +- **FIELD_TYPE** (required): One of `signature`, `initials`, `name`, `email`, `date`, `text`, `number`, `radio`, `checkbox`, `dropdown` +- **RECIPIENT** (required): `r1`, `r2`, `r3`, etc. - identifies which recipient the field belongs to +- **OPTIONS** (optional): Key-value pairs like `required=true`, `fontSize=14`, `readOnly=true` + +### Examples + +- `{{signature, r1}}` - Signature field for first recipient +- `{{text, r1, required=true, label=Company Name}}` - Required text field with label +- `{{number, r2, minValue=0, maxValue=100}}` - Number field with validation + +### Behavior + +- Placeholders without recipient identifiers (e.g., `{{signature}}`) are skipped during automatic detection - reserved for API use +- Invalid field types are silently skipped +- Placeholder text is covered with white rectangles after field creation + +## API Placeholder Positioning + +The `/api/v2/envelope/field/create-many` endpoint accepts `placeholder` as an alternative to coordinates: + +```json +{ + "recipientId": 123, + "type": "SIGNATURE", + "placeholder": "{{signature}}" +} +``` + +### Parameters + +| Parameter | Type | Description | +| ------------- | ------- | -------------------------------------------- | +| `placeholder` | string | Text to search for in the PDF | +| `width` | number | Optional override (percentage) | +| `height` | number | Optional override (percentage) | +| `matchAll` | boolean | When true, creates fields at ALL occurrences | + +### matchAll Behavior + +- Default (`false`): Only first occurrence gets a field +- `true`: Creates a field at every occurrence of the placeholder text + +This is useful for documents requiring initials on every page. + +## Implementation Components + +### Core Functions + +- `extractPlaceholdersFromPDF()` - Scans PDF for `{{...}}` patterns with recipient identifiers +- `removePlaceholdersFromPDF()` - Covers placeholder text with white rectangles +- `whiteoutRegions()` - Low-level helper for drawing white boxes on PDF pages +- `parseFieldTypeFromPlaceholder()` - Converts placeholder field type to FieldType enum +- `parseFieldMetaFromPlaceholder()` - Parses options into fieldMeta format + +### Integration Points + +1. **Upload flow** (`create-envelope.ts`, `create-envelope-items.ts`) + - Extract placeholders at upload time (before saving to storage) + - Pass placeholders in-memory to envelope creation + - Create placeholder recipients if none provided + - Create fields within the same transaction + +2. **API field creation** (`create-envelope-fields.ts`) + - Accept `placeholder` as alternative to coordinates + - Search PDF for placeholder text + - Resolve position from bounding box + - Support `matchAll` for multiple occurrences + +### Field Meta Parsing + +The following properties are explicitly parsed: + +- `required`, `readOnly` → boolean +- `fontSize`, `minValue`, `maxValue`, `characterLimit` → number +- Other properties pass through as strings + +Note: Signature fields do not support fieldMeta options. + +## Testing + +### E2E Tests + +**UI Tests** (`e2e/auto-placing-fields/`): + +- Single recipient placeholder detection +- Multiple recipient placeholder detection +- Field configuration from placeholder options +- Skipping placeholders without recipient identifiers +- Skipping invalid field types + +**API Tests** (`e2e/api/v2/placeholder-fields-api.spec.ts`): + +- Placeholder-based field positioning +- Width/height overrides +- Error on placeholder not found +- Mixed coordinate and placeholder positioning +- First occurrence only (default) +- All occurrences with `matchAll: true` + +## Documentation + +### User Documentation + +`/users/documents/pdf-placeholders` - Explains: + +- Placeholder format and syntax +- Supported field types +- Recipient identifiers +- Available options per field type +- Troubleshooting + +### Developer Documentation + +`/developers/public-api/reference` - Documents: + +- Coordinate-based positioning (existing) +- Placeholder-based positioning (new) +- matchAll parameter +- Mixing both methods + +## Edge Cases Handled + +1. **No placeholders found** - Original PDF returned unchanged +2. **Placeholder not found (API)** - Returns error with placeholder text +3. **Multiple occurrences** - First only by default, all with `matchAll: true` +4. **No recipient identifier** - Skipped during auto-detection, works for API +5. **Invalid field type** - Skipped during auto-detection +6. **Signature field with options** - Options ignored (signature doesn't support fieldMeta) + +## Future Considerations + +- Support for placeholder text styles (bold, underline) to indicate field properties +- Template-level placeholder mapping for reusable configurations +- Placeholder validation in document editor before sending diff --git a/.env.example b/.env.example index 7e2b8cb34..29fffa4e9 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# The license key to enable enterprise features for self hosters +NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY= + # [[AUTH]] NEXTAUTH_SECRET="secret" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 962da1ec9..50137d2e1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,6 +3,12 @@ name: Publish Docker on: push: branches: ['release'] + workflow_dispatch: + inputs: + tag: + description: 'Git tag to build and publish (e.g., v1.0.0)' + required: true + type: string jobs: build_and_publish_platform_containers: @@ -18,6 +24,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + ref: ${{ inputs.tag || github.ref }} fetch-tags: true - name: Login to DockerHub @@ -38,8 +45,11 @@ jobs: BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }} NEXT_PRIVATE_TELEMETRY_KEY: ${{ secrets.NEXT_PRIVATE_TELEMETRY_KEY }} NEXT_PRIVATE_TELEMETRY_HOST: ${{ secrets.NEXT_PRIVATE_TELEMETRY_HOST }} + APP_VERSION: ${{ inputs.tag || '' }} run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + if [ -z "$APP_VERSION" ]; then + APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + fi GIT_SHA="$(git rev-parse HEAD)" docker build \ @@ -65,47 +75,6 @@ jobs: env: BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }} - - name: Build the chromium docker image - env: - BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }} - run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" - GIT_SHA="$(git rev-parse HEAD)" - - docker build \ - -f ./docker/Dockerfile.chromium \ - --progress=plain \ - --build-arg TAG="$GIT_SHA" \ - -t "documenso/documenso-$BUILD_PLATFORM:latest-chromium" \ - -t "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" \ - -t "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \ - -t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest-chromium" \ - -t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" \ - -t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \ - . - - - name: Push the chromium docker image to DockerHub - env: - BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }} - run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" - GIT_SHA="$(git rev-parse HEAD)" - - docker push "documenso/documenso-$BUILD_PLATFORM:latest-chromium" - docker push "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" - docker push "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \ - - - name: Push the chromium docker image to GitHub Container Registry - env: - BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }} - run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" - GIT_SHA="$(git rev-parse HEAD)" - - docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest-chromium" - docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" - docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" - create_and_publish_manifest: name: Create and publish manifest runs-on: ubuntu-latest @@ -114,6 +83,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: + ref: ${{ inputs.tag || github.ref }} fetch-tags: true - name: Login to DockerHub @@ -130,8 +100,12 @@ jobs: password: ${{ secrets.GH_TOKEN }} - name: Create and push DockerHub manifest + env: + APP_VERSION: ${{ inputs.tag || '' }} run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + if [ -z "$APP_VERSION" ]; then + APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + fi GIT_SHA="$(git rev-parse HEAD)" # Check if the version is stable (no rc or beta in the version) @@ -166,46 +140,13 @@ jobs: docker manifest push documenso/documenso:$GIT_SHA docker manifest push documenso/documenso:$APP_VERSION - - name: Create and push DockerHub chromium manifest - run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" - GIT_SHA="$(git rev-parse HEAD)" - - # Check if the version is stable (no rc or beta in the version) - if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - docker manifest create \ - documenso/documenso:latest-chromium \ - --amend documenso/documenso-amd64:latest-chromium \ - --amend documenso/documenso-arm64:latest-chromium - - docker manifest push documenso/documenso:latest-chromium - fi - - if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then - docker manifest create \ - documenso/documenso:rc-chromium \ - --amend documenso/documenso-amd64:rc-chromium \ - --amend documenso/documenso-arm64:rc-chromium - - docker manifest push documenso/documenso:rc-chromium - fi - - docker manifest create \ - documenso/documenso:$GIT_SHA-chromium \ - --amend documenso/documenso-amd64:$GIT_SHA-chromium \ - --amend documenso/documenso-arm64:$GIT_SHA-chromium - - docker manifest create \ - documenso/documenso:$APP_VERSION-chromium \ - --amend documenso/documenso-amd64:$APP_VERSION-chromium \ - --amend documenso/documenso-arm64:$APP_VERSION-chromium - - docker manifest push documenso/documenso:$GIT_SHA-chromium - docker manifest push documenso/documenso:$APP_VERSION-chromium - - name: Create and push Github Container Registry manifest + env: + APP_VERSION: ${{ inputs.tag || '' }} run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + if [ -z "$APP_VERSION" ]; then + APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + fi GIT_SHA="$(git rev-parse HEAD)" # Check if the version is stable (no rc or beta in the version) @@ -239,40 +180,3 @@ jobs: docker manifest push ghcr.io/documenso/documenso:$GIT_SHA docker manifest push ghcr.io/documenso/documenso:$APP_VERSION - - - name: Create and push Github Container Registry chromium manifest - run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" - GIT_SHA="$(git rev-parse HEAD)" - - # Check if the version is stable (no rc or beta in the version) - if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - docker manifest create \ - ghcr.io/documenso/documenso:latest-chromium \ - --amend ghcr.io/documenso/documenso-amd64:latest-chromium \ - --amend ghcr.io/documenso/documenso-arm64:latest-chromium - - docker manifest push ghcr.io/documenso/documenso:latest-chromium - fi - - if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then - docker manifest create \ - ghcr.io/documenso/documenso:rc-chromium \ - --amend ghcr.io/documenso/documenso-amd64:rc-chromium \ - --amend ghcr.io/documenso/documenso-arm64:rc-chromium - - docker manifest push ghcr.io/documenso/documenso:rc-chromium - fi - - docker manifest create \ - ghcr.io/documenso/documenso:$GIT_SHA-chromium \ - --amend ghcr.io/documenso/documenso-amd64:$GIT_SHA-chromium \ - --amend ghcr.io/documenso/documenso-arm64:$GIT_SHA-chromium - - docker manifest create \ - ghcr.io/documenso/documenso:$APP_VERSION-chromium \ - --amend ghcr.io/documenso/documenso-amd64:$APP_VERSION-chromium \ - --amend ghcr.io/documenso/documenso-arm64:$APP_VERSION-chromium - - docker manifest push ghcr.io/documenso/documenso:$GIT_SHA-chromium - docker manifest push ghcr.io/documenso/documenso:$APP_VERSION-chromium diff --git a/.gitignore b/.gitignore index e85bd9780..961230226 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,7 @@ CLAUDE.md # scripts scripts/output* + +# license +.documenso-license.json +.documenso-license-backup.json diff --git a/apps/documentation/pages/developers/_meta.js b/apps/documentation/pages/developers/_meta.js index 2f0f9b24d..4286dc3a3 100644 --- a/apps/documentation/pages/developers/_meta.js +++ b/apps/documentation/pages/developers/_meta.js @@ -13,6 +13,7 @@ export default { title: 'API & Integration Guides', }, 'public-api': 'Public API', - embedding: 'Embedding', + embedding: 'Embedded Signing', + 'embedded-authoring': 'Embedded Authoring', webhooks: 'Webhooks', }; diff --git a/apps/documentation/pages/developers/embedding/authoring.mdx b/apps/documentation/pages/developers/embedded-authoring.mdx similarity index 94% rename from apps/documentation/pages/developers/embedding/authoring.mdx rename to apps/documentation/pages/developers/embedded-authoring.mdx index d3427f1c8..4cf6fd659 100644 --- a/apps/documentation/pages/developers/embedding/authoring.mdx +++ b/apps/documentation/pages/developers/embedded-authoring.mdx @@ -1,12 +1,25 @@ --- -title: Authoring +title: Embedded Authoring description: Learn how to use embedded authoring to create documents and templates in your application --- +import { Callout } from 'nextra/components'; + + + The embedded authoring feature is an enterprise only feature. Please contact us if you are + interested in using it. + + # Embedded Authoring In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation and editing directly within your application. +## Embedded Signing vs Embedded Authoring + +Embedded signing allows you to embed your Documenso documents into your application for signing. Your users will be able to sign the document directly in your application. + +Embedded authoring allows you to integrate Documenso's document and template creation and editing into your application. You will be able to create and edit documents and templates directly in your application. + ## How Embedded Authoring Works The embedded authoring feature enables your users to create and edit documents and templates without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions. diff --git a/apps/documentation/pages/developers/embedding/_meta.js b/apps/documentation/pages/developers/embedding/_meta.js index bfa67f172..c666bdaa1 100644 --- a/apps/documentation/pages/developers/embedding/_meta.js +++ b/apps/documentation/pages/developers/embedding/_meta.js @@ -7,5 +7,4 @@ export default { preact: 'Preact Integration', angular: 'Angular Integration', 'css-variables': 'CSS Variables', - authoring: 'Authoring', }; diff --git a/apps/documentation/pages/developers/embedding/index.mdx b/apps/documentation/pages/developers/embedding/index.mdx index 7fd3d4193..6d8d6531a 100644 --- a/apps/documentation/pages/developers/embedding/index.mdx +++ b/apps/documentation/pages/developers/embedding/index.mdx @@ -3,10 +3,16 @@ title: Get Started description: Learn how to use embedding to bring signing to your own website or application --- -# Embedding +# Embedded Signing Our embedding feature lets you integrate our document signing experience into your own application or website. Whether you're building with React, Preact, Vue, Svelte, Solid, Angular, or using generalized web components, this guide will help you get started with embedding Documenso. +## Embedded Signing vs Embedded Authoring + +Embedded signing allows you to embed your Documenso documents into your application for signing. Your users will be able to sign the document directly in your application. + +Embedded authoring allows you to integrate Documenso's document and template creation and editing into your application. You will be able to create and edit documents and templates directly in your application. + ## Availability Embedding is currently available for all users on a **Teams Plan** and above, as well as **Early Adopter's** within a team (Early Adopters can create a team for free). diff --git a/apps/documentation/pages/developers/public-api/index.mdx b/apps/documentation/pages/developers/public-api/index.mdx index f589c6c0f..1c844c394 100644 --- a/apps/documentation/pages/developers/public-api/index.mdx +++ b/apps/documentation/pages/developers/public-api/index.mdx @@ -31,9 +31,18 @@ Our new API V2 supports the following typed SDKs: ## API V1 - Deprecated -Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods. + + API V1 is deprecated. +
+ The V1 API will continue to be supported for the foreseeable future, but it is limited to + Legacy Documents (Documents created using the old non-envelope editor). -📖 [Documentation](https://documen.so/api-v2-docs) + Important: To work with the new Envelope document system, you + must use the + V2 API. +
+ +Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods. ## Availability diff --git a/apps/documentation/pages/developers/public-api/reference.mdx b/apps/documentation/pages/developers/public-api/reference.mdx index 19b1c9242..ab8c57bc8 100644 --- a/apps/documentation/pages/developers/public-api/reference.mdx +++ b/apps/documentation/pages/developers/public-api/reference.mdx @@ -316,6 +316,8 @@ Before adding fields to an envelope, you will need the following details: See the [Get Envelope](#get-envelope) section for more details on how to retrieve these details. +### Coordinate-Based Positioning + The following is an example of a request which creates 2 new fields on the first page of the envelope. Note that width, height, positionX and positionY are percentage numbers between 0 and 100, which scale the field relative to the size of the PDF. @@ -360,6 +362,95 @@ curl https://app.documenso.com/api/v2/envelope/field/create-many \ }' ``` +### Placeholder-Based Positioning + +Instead of specifying exact coordinates, you can position fields using placeholder text in the PDF. The API will search for the text and place the field at that location. + +This is useful when: + +- You have PDFs with designated placeholder text (e.g., `{{signature}}`, `[SIGN HERE]`) +- You want field positions to adapt to document content changes +- You're working with templated documents generated from other systems + +```sh +curl https://app.documenso.com/api/v2/envelope/field/create-many \ + --request POST \ + --header 'Authorization: api_xxxxxxxxxxxxxx' \ + --header 'Content-Type: application/json' \ + --data '{ + "envelopeId": "envelope_xxxxxxxxxx", + "data": [ + { + "recipientId": recipient_id_here, + "type": "SIGNATURE", + "placeholder": "{{signature}}" + }, + { + "recipientId": recipient_id_here, + "type": "NAME", + "placeholder": "{{name}}", + "width": 30, + "height": 5 + } + ] + }' +``` + +#### Placeholder Parameters + +| Parameter | Type | Required | Description | +| ------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------- | +| `placeholder` | string | Yes | Text to search for in the PDF. The field is placed at the location of this text. | +| `width` | number | No | Override the field width (percentage). If omitted, uses the placeholder text width. | +| `height` | number | No | Override the field height (percentage). If omitted, uses the placeholder text height. | +| `matchAll` | boolean | No | When `true`, creates a field at every occurrence of the placeholder. Default is `false` (first occurrence only). | + + + The placeholder text is automatically covered with a white rectangle after field creation, so it + won't appear in the final signed document. + + +#### Multiple Occurrences + +If your PDF contains the same placeholder text multiple times (e.g., initials on every page), use `matchAll: true` to create fields at all occurrences: + +```json +{ + "recipientId": 123, + "type": "INITIALS", + "placeholder": "{{initials}}", + "matchAll": true +} +``` + +This will create one INITIALS field for each occurrence of `{{initials}}` in the PDF. + +#### Mixing Positioning Methods + +You can combine coordinate-based and placeholder-based positioning in the same request: + +```json +{ + "envelopeId": "envelope_xxxxxxxxxx", + "data": [ + { + "recipientId": 123, + "type": "SIGNATURE", + "placeholder": "{{signature}}" + }, + { + "recipientId": 123, + "type": "DATE", + "page": 1, + "positionX": 70, + "positionY": 85, + "width": 20, + "height": 3 + } + ] +} +``` + Field meta allows you to further configure fields, for example it will allow you to add multiple items for checkboxes or radios. A successful request will return a JSON response with the newly added fields. diff --git a/apps/documentation/pages/developers/webhooks.mdx b/apps/documentation/pages/developers/webhooks.mdx index a9684a9cc..6626fb6cb 100644 --- a/apps/documentation/pages/developers/webhooks.mdx +++ b/apps/documentation/pages/developers/webhooks.mdx @@ -544,7 +544,7 @@ Example payload for the `document.rejected` event: } ``` -Example payload for the `document.rejected` event: +Example payload for the `document.cancelled` event: ```json { diff --git a/apps/documentation/pages/users/documents/_meta.js b/apps/documentation/pages/users/documents/_meta.js index b9ed38aa9..7db45bbdf 100644 --- a/apps/documentation/pages/users/documents/_meta.js +++ b/apps/documentation/pages/users/documents/_meta.js @@ -3,6 +3,7 @@ export default { 'document-preferences': 'Document Preferences', 'document-visibility': 'Document Visibility', fields: 'Document Fields', + 'pdf-placeholders': 'PDF Placeholders', 'email-preferences': 'Email Preferences', 'ai-detection': 'AI Recipient & Field Detection', 'default-recipients': 'Default Recipients', diff --git a/apps/documentation/pages/users/documents/pdf-placeholders.mdx b/apps/documentation/pages/users/documents/pdf-placeholders.mdx new file mode 100644 index 000000000..c3b05102b --- /dev/null +++ b/apps/documentation/pages/users/documents/pdf-placeholders.mdx @@ -0,0 +1,179 @@ +--- +title: PDF Placeholders +description: Learn how to use placeholder text in your PDFs for automatic field placement in Documenso. +--- + +import { Callout } from 'nextra/components'; + +# PDF Placeholders + +Documenso can automatically detect placeholder text in your PDF documents and create fields at those locations. This allows you to prepare documents in your preferred editing tool (Word, Google Docs, etc.) with placeholders that become signature fields when uploaded. + +## How It Works + +When you upload a PDF, Documenso scans for text matching the placeholder pattern `{{...}}`. Each placeholder can specify: + +1. **Field type** - What kind of field to create (signature, name, email, etc.) +2. **Recipient** - Which signer the field belongs to (r1, r2, etc.) +3. **Options** - Additional settings like required, read-only, font size, etc. + +The placeholder text is automatically hidden after fields are created, so your final document looks clean. + +## Placeholder Format + +The basic format is: + +``` +{{FIELD_TYPE, RECIPIENT, option1=value1, option2=value2}} +``` + +### Examples + +| Placeholder | Description | +| ----------------------------- | ----------------------------------- | +| `{{signature, r1}}` | Signature field for recipient 1 | +| `{{name, r1}}` | Name field for recipient 1 | +| `{{email, r2}}` | Email field for recipient 2 | +| `{{date, r1}}` | Date field for recipient 1 | +| `{{text, r1, required=true}}` | Required text field for recipient 1 | +| `{{initials, r1}}` | Initials field for recipient 1 | + +## Supported Field Types + +The following field types are supported in placeholders: + +| Field Type | Placeholder Value | +| ---------- | ----------------- | +| Signature | `signature` | +| Initials | `initials` | +| Name | `name` | +| Email | `email` | +| Date | `date` | +| Text | `text` | +| Number | `number` | +| Radio | `radio` | +| Checkbox | `checkbox` | +| Dropdown | `dropdown` | + + + Field types are case-insensitive. `{{ SIGNATURE, r1 }}` and `{{ signature, r1 }}` are equivalent. + + +## Recipient Identifiers + +Recipients are identified using `r1`, `r2`, `r3`, etc. The number corresponds to the order in which recipients are created: + +- `r1` - First recipient +- `r2` - Second recipient +- `r3` - Third recipient + +When you upload a PDF with placeholders, Documenso will: + +1. Create placeholder recipients for each unique identifier found (e.g., `r1`, `r2`) +2. You can then update these with real email addresses before sending + + + Placeholders without a recipient identifier (e.g., `{{ signature }}` without `r1`) are reserved + for API use and will not create fields during upload. + + +## Field Options + +You can customize fields by adding options after the recipient identifier: + +### Common Options + +| Option | Values | Description | +| ----------- | ------------------------- | ------------------------------------------ | +| `required` | `true`, `false` | Whether the field must be filled | +| `readOnly` | `true`, `false` | Whether the field is pre-filled and locked | +| `fontSize` | Number (e.g., `12`) | Font size in points | +| `textAlign` | `left`, `center`, `right` | Horizontal text alignment | + +### Text Field Options + +| Option | Values | Description | +| ---------------- | ------ | ------------------------------------- | +| `label` | Text | Label shown in the field | +| `placeholder` | Text | Placeholder text shown before signing | +| `text` | Text | Pre-filled text value | +| `characterLimit` | Number | Maximum characters allowed | + +### Number Field Options + +| Option | Values | Description | +| -------------- | ------------- | --------------------- | +| `value` | Number | Pre-filled value | +| `minValue` | Number | Minimum allowed value | +| `maxValue` | Number | Maximum allowed value | +| `numberFormat` | Format string | Number display format | + +### Examples with Options + +``` +{{text, r1, required=true, label=Company Name}} +{{number, r1, minValue=0, maxValue=100, value=50}} +{{name, r1, fontSize=14}} +{{text, r2, readOnly=true, text=Contract #12345}} +``` + + + Signature and Free Signature fields do not support additional options beyond the field type and + recipient. + + +## Multiple Recipients Example + +Here's how a document might look with placeholders for two signers: + +``` +AGREEMENT + +Party A Signature: {{signature, r1}} +Party A Name: {{name, r1}} +Party A Date: {{date, r1}} + +Party B Signature: {{signature, r2}} +Party B Name: {{name, r2}} +Party B Date: {{date, r2}} +``` + +When uploaded, this creates: + +- 3 fields assigned to recipient 1 (Party A) +- 3 fields assigned to recipient 2 (Party B) +- 2 placeholder recipients that you can update with real email addresses + +## Tips for Creating Documents + +1. **Use a readable font** - Placeholders need to be readable by the PDF parser. Standard fonts like Arial, Helvetica, or Times New Roman work best. + +2. **Don't split placeholders** - Ensure the entire placeholder text `{{...}}` is on a single line and not broken across text boxes. + +3. **Size matters** - The field will be sized to match the placeholder text width. Use spaces or longer placeholder text if you need wider fields. + +4. **Test with a draft** - Upload your document as a draft first to verify fields are detected correctly before sending. + + + Placeholder detection happens automatically when you upload a PDF. You can review and adjust the + created fields in the document editor before sending. + + +## Troubleshooting + +### Placeholders Not Detected + +- Ensure placeholders use double curly braces: `{{...}}` +- Check that the placeholder includes a recipient identifier (e.g., `r1`) +- Verify the field type is spelled correctly +- Try using a standard font in your source document + +### Wrong Field Position + +- The field is placed at the exact location of the placeholder text +- If the position seems off, check that your PDF wasn't scaled or reformatted when exported + +### Placeholder Text Still Visible + +- Placeholder text is covered with a white rectangle after field creation +- If you see the text, try re-uploading the document diff --git a/apps/documentation/pages/users/fair-use.mdx b/apps/documentation/pages/users/fair-use.mdx index 2fb4198dd..52ddd6b8b 100644 --- a/apps/documentation/pages/users/fair-use.mdx +++ b/apps/documentation/pages/users/fair-use.mdx @@ -7,28 +7,41 @@ import { Callout } from 'nextra/components'; # Fair Use Policy -We offer our plans without any limits on volume because we want our users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using. +We like to overdeliver, but we cannot overcommit. -This is why the individual plan and the team plan do not include a limit on signing or API volume. If you are a customer of these [plans](https://documen.so/pricing), we ask you to abide by this fair use policy: +Our plans are designed to be generous and flexible without forcing customers into rigid volume limits they may never use. At the same time, estimating usage at scale is hard, especially over short periods. This fair use policy exists to keep plans sustainable while allowing us to add more value wherever possible without overformalizing restrictions. + +We offer our plans without any limits on volume because we want users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using. + +This is why our plans not include a limit on signing or API volume. If you are a customer of these plans, we ask you to abide by this fair use policy. ### Spirit of the Plan -Use the limitless accounts as much as you like (they are meant to offer a lot) while respecting the spirit and intended scope of the account. +Use the limitless plans as much as you like. They are meant to offer a lot. Please respect the spirit and intended scope of the account. - What happens if I violate this policy? We will ask you to upgrade to a fitting plan or custom - pricing. We won’t block your account without reaching out. You can [message - us](mailto:support@documenso.com) for questions. + What happens if I go beyond the scope of this policy? We will ask you to upgrade to a fitting plan + or custom pricing. We will not block your account without reaching out. You can message us for + questions. +### Fair Support + +We believe in fair support as much as fair usage. + +Fair support includes reasonable and within reason application level help for self hosted users. We will help you get unstuck and point you in the right direction when issues come up. Support is provided in good faith and within reasonable time and effort limits. We are not your operations team and cannot take responsibility for running, monitoring, or maintaining your infrastructure. + +If you are unsure whether something falls within fair use or fair support, reach out. We are happy to talk it through. + ### DO -- Sign as many documents as you need with the individual plan for your single business or organization you are part of -- Use the API and automation tools to automate all your signing workflows -- Experiment with the plans and integrations, testing what you want to build +- Sign as many documents as you need with the individual plan for your single business or organization +- Use the API and automation tools to automate your signing workflows +- Experiment with plans and integrations while testing what you want to build ### DON'T -- Use the individual account's API to power a platform -- Run a huge company, signing thousands of documents per day on a two-user team plan using the API -- Let this policy make you overthink. If you are a paying customer, we want you to win +- Use an individual account API to power a platform or product +- Run a large company signing thousands of documents per day on a small team plan +- Expect enterprise level support for fair support plan +- Overthink this policy. If you are a paying customer, we want you to win diff --git a/apps/documentation/pages/users/licenses/enterprise-edition.mdx b/apps/documentation/pages/users/licenses/enterprise-edition.mdx index b1f6a77b2..76ed9a63b 100644 --- a/apps/documentation/pages/users/licenses/enterprise-edition.mdx +++ b/apps/documentation/pages/users/licenses/enterprise-edition.mdx @@ -7,20 +7,51 @@ import { Callout } from 'nextra/components'; # Enterprise Edition -The Documenso Enterprise Edition is our license for self-hosters that need the full range of support and compliance. Everything in the EE folder and all features listed [here](https://github.com/documenso/documenso/blob/main/packages/ee/FEATURES) can be used after acquiring a paid license. - -## Includes - -- Self-Host Documenso in any context. -- Premium Support via Slack, Discord and Email. -- Flexible Licensing (e.g. MIT) for deeper custom integration (if needed). -- Access to all Enterprise-grade compliance and administration features. - -## Limitations - -The Enterprise Edition currently has no limitations except custom contract terms. - The Enterprise Edition requires a paid subscription. [Contact us for a quote](https://documen.so/enterprise). + +The Documenso Enterprise Edition is our license for self-hosters that need the full range of support and compliance. + +The following features are included in the Enterprise Edition: + +{/* Keep this synced with the packages/ee/FEATURES file */} + +- The Stripe Billing Module +- Organisation Authentication Portal +- Document Action Reauthentication (Passkeys and 2FA) +- 21 CFR +- Email domains +- Embed authoring +- Embed authoring white label + +In addition, you will receive: + +- Premium Support via Slack, Discord and Email. +- Flexible Licensing (e.g. MIT) for deeper custom integration (if needed). +- Access to Enterprise-grade compliance and administration features. +- Permission to self-Host Documenso in any context. + +The Enterprise Edition currently has no limitations except custom contract terms. + +## Getting a License + +To acquire an Enterprise Edition license, please [contact our sales team](https://documen.so/enterprise) for a quote. Our team will work with you to understand your requirements and provide a license that fits your needs. + +## Using Your License + +Once you have acquired an Enterprise Edition license: + +1. Access your license key at [license.documenso.com](https://license.documenso.com) +2. Set the `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` environment variable in your Documenso instance with your license key + +```bash +NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY="your-license-key-here" +``` + +3. You can verify your license status in the Admin Panel under the Stats section. + +![Admin License Status](/images/admin-license-status.webp) + +Your license will be verified on startup and periodically to ensure continued access to Enterprise features. diff --git a/apps/documentation/public/images/admin-license-status.webp b/apps/documentation/public/images/admin-license-status.webp new file mode 100644 index 000000000..2dad3b2da Binary files /dev/null and b/apps/documentation/public/images/admin-license-status.webp differ diff --git a/apps/remix/app/components/dialogs/claim-create-dialog.tsx b/apps/remix/app/components/dialogs/claim-create-dialog.tsx index c3564e7e0..589484693 100644 --- a/apps/remix/app/components/dialogs/claim-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/claim-create-dialog.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { Trans, useLingui } from '@lingui/react/macro'; import type { z } from 'zod'; +import type { TLicenseClaim } from '@documenso/lib/types/license'; import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims'; import { trpc } from '@documenso/trpc/react'; import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types'; @@ -22,7 +23,11 @@ import { SubscriptionClaimForm } from '../forms/subscription-claim-form'; export type CreateClaimFormValues = z.infer; -export const ClaimCreateDialog = () => { +type ClaimCreateDialogProps = { + licenseFlags?: TLicenseClaim; +}; + +export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => { const { t } = useLingui(); const { toast } = useToast(); @@ -67,6 +72,7 @@ export const ClaimCreateDialog = () => { ...generateDefaultSubscriptionClaim(), }} onFormSubmit={createClaim} + licenseFlags={licenseFlags} formSubmitTrigger={ + + + + + + ); +}; diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx new file mode 100644 index 000000000..be50e85a2 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx @@ -0,0 +1,256 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Plural, useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { EnvelopeType } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EnvelopesBulkMoveDialogProps = { + envelopeIds: string[]; + envelopeType: EnvelopeType; + open: boolean; + onOpenChange: (open: boolean) => void; + currentFolderId?: string; + onSuccess?: () => void; +} & Omit; + +const ZBulkMoveFormSchema = z.object({ + folderId: z.string().nullable(), +}); + +type TBulkMoveFormSchema = z.infer; + +export const EnvelopesBulkMoveDialog = ({ + envelopeIds, + envelopeType, + open, + onOpenChange, + currentFolderId, + onSuccess, + ...props +}: EnvelopesBulkMoveDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [searchTerm, setSearchTerm] = useState(''); + + const form = useForm({ + resolver: zodResolver(ZBulkMoveFormSchema), + defaultValues: { + folderId: currentFolderId ?? null, + }, + }); + + const isDocument = envelopeType === EnvelopeType.DOCUMENT; + + const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery( + { + parentId: currentFolderId, + type: envelopeType, + }, + { + enabled: open, + }, + ); + + const { mutateAsync: bulkMoveEnvelopes } = trpc.envelope.bulk.move.useMutation(); + + const trpcUtils = trpc.useUtils(); + + useEffect(() => { + if (open) { + setSearchTerm(''); + + form.reset({ + folderId: currentFolderId, + }); + } + }, [open, currentFolderId]); + + const onSubmit = async (data: TBulkMoveFormSchema) => { + try { + await bulkMoveEnvelopes({ + envelopeIds, + folderId: data.folderId, + envelopeType, + }); + + // Invalidate the appropriate query based on envelope type. + if (isDocument) { + await trpcUtils.document.findDocumentsInternal.invalidate(); + } else { + await trpcUtils.template.findTemplates.invalidate(); + } + + toast({ + description: t`Selected items have been moved.`, + }); + + onSuccess?.(); + onOpenChange(false); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with( + AppErrorCode.NOT_FOUND, + () => t`The folder you are trying to move the items to does not exist.`, + ) + .with(AppErrorCode.UNAUTHORIZED, () => t`You are not allowed to move these items.`) + .with(AppErrorCode.INVALID_BODY, () => t`All items must be of the same type.`) + .otherwise(() => t`An error occurred while moving the items.`); + + toast({ + description: errorMessage, + variant: 'destructive', + }); + } + }; + + const filteredFolders = folders?.data.filter((folder) => + folder.name.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + return ( + + + + + {isDocument ? ( + Move Documents to Folder + ) : ( + Move Templates to Folder + )} + + + + {isDocument ? ( + + ) : ( + + )} + + + +
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+ +
+ + ( + + + Folder + + + +
+ {isFoldersLoading ? ( +
+ +
+ ) : ( + <> + + + {filteredFolders?.map((folder) => ( + + ))} + + {searchTerm && filteredFolders?.length === 0 && ( +
+ No folders found +
+ )} + + )} +
+
+ +
+ )} + /> + + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/team-member-create-dialog.tsx b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx index 8f822cfad..b8e5cfef5 100644 --- a/apps/remix/app/components/dialogs/team-member-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx @@ -1,10 +1,10 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import { TeamMemberRole } from '@prisma/client'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; -import { InfoIcon } from 'lucide-react'; +import { InfoIcon, UserPlusIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { Link } from 'react-router'; import { match } from 'ts-pattern'; @@ -13,6 +13,7 @@ import { z } from 'zod'; import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams'; import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -44,6 +45,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog'; import { useCurrentTeam } from '~/providers/team'; export type TeamMemberCreateDialogProps = { @@ -64,11 +66,14 @@ type TAddTeamMembersFormSchema = z.infer; export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => { const [open, setOpen] = useState(false); const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT'); + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const prevInviteDialogOpenRef = useRef(false); const { t } = useLingui(); const { toast } = useToast(); const team = useCurrentTeam(); + const utils = trpc.useUtils(); const form = useForm({ resolver: zodResolver(ZAddTeamMembersFormSchema), @@ -96,7 +101,29 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi ); }, [organisationMemberQuery, teamMemberQuery]); + const hasNoAvailableMembers = + !organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0; + const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => { + if (members.length === 0) { + if (hasNoAvailableMembers) { + setInviteDialogOpen(true); + return; + } + + // Don't show error if on SELECT step - the disabled Next button already communicates this + if (step === 'SELECT') { + return; + } + + toast({ + title: t`No members selected`, + description: t`Please select at least one member to add to the team.`, + variant: 'destructive', + }); + return; + } + try { await createTeamMembers({ teamId: team.id, @@ -123,9 +150,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi if (!open) { form.reset(); setStep('SELECT'); + setInviteDialogOpen(false); } }, [open, form]); + // Invalidate queries when invite dialog closes (transitions from true to false) to refresh available members + useEffect(() => { + if (prevInviteDialogOpenRef.current && !inviteDialogOpen) { + void utils.organisation.member.find.invalidate({ + organisationId: team.organisationId, + }); + } + prevInviteDialogOpenRef.current = inviteDialogOpen; + }, [inviteDialogOpen, utils, team.organisationId]); + return ( e.stopPropagation()} asChild> - + {trigger ?? ( + + )} @@ -149,7 +189,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi - + To be able to add members to a team, you must first add them to the organisation. For more information, please see the{' '} @@ -186,7 +226,18 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi .exhaustive()}
- + { + if (e.key === 'Enter' && form.getValues('members').length === 0) { + e.preventDefault(); + if (hasNoAvailableMembers) { + setInviteDialogOpen(true); + } + // Don't show toast - the disabled Next button already communicates this + } + }} + >
{step === 'SELECT' && ( <> @@ -194,46 +245,102 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi control={form.control} name="members" render={({ field }) => ( - + Members - ({ - label: member.name, - value: member.id, - }))} - loading={organisationMemberQuery.isLoading} - selectedValues={field.value.map( - (member) => member.organisationMemberId, - )} - onChange={(value) => { - field.onChange( - value.map((organisationMemberId) => ({ - organisationMemberId, - teamRole: - field.value.find( - (member) => - member.organisationMemberId === organisationMemberId, - )?.teamRole || TeamMemberRole.MEMBER, - })), - ); - }} - className="bg-background w-full" - emptySelectionPlaceholder={t`Select members`} - /> + {hasNoAvailableMembers ? ( +
+
+ +
+

+ No organisation members available +

+

+ + To add members to this team, you must first add them to the + organisation. + +

+ + + Invite organisation members + + } + /> +
+ ) : ( + ({ + label: member.name, + value: member.id, + }))} + loading={organisationMemberQuery.isLoading} + selectedValues={field.value.map( + (member) => member.organisationMemberId, + )} + onChange={(value) => { + field.onChange( + value.map((organisationMemberId) => ({ + organisationMemberId, + teamRole: + field.value.find( + (member) => + member.organisationMemberId === organisationMemberId, + )?.teamRole || TeamMemberRole.MEMBER, + })), + ); + }} + className="w-full bg-background" + emptySelectionPlaceholder={t`Select members`} + /> + )}
- - Select members to add to this team - + {!hasNoAvailableMembers && ( + <> + + Select members to add to this team + + + +
+ +
+ + Can't find someone?{' '} + + Invite them to the organisation first + + } + /> + +
+ + )}
)} /> - + diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index 1c4be853f..39cc4f0f3 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -402,7 +402,7 @@ export const SignUpForm = ({ size="lg" className="mt-6 w-full" > - Complete + Create account diff --git a/apps/remix/app/components/forms/subscription-claim-form.tsx b/apps/remix/app/components/forms/subscription-claim-form.tsx index f375d5af1..7b228f189 100644 --- a/apps/remix/app/components/forms/subscription-claim-form.tsx +++ b/apps/remix/app/components/forms/subscription-claim-form.tsx @@ -2,10 +2,13 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import type { SubscriptionClaim } from '@prisma/client'; import { useForm } from 'react-hook-form'; +import { Link } from 'react-router'; import type { z } from 'zod'; +import type { TLicenseClaim } from '@documenso/lib/types/license'; import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Form, @@ -24,15 +27,22 @@ type SubscriptionClaimFormProps = { subscriptionClaim: Omit; onFormSubmit: (data: SubscriptionClaimFormValues) => Promise; formSubmitTrigger?: React.ReactNode; + licenseFlags?: TLicenseClaim; }; export const SubscriptionClaimForm = ({ subscriptionClaim, onFormSubmit, formSubmitTrigger, + licenseFlags, }: SubscriptionClaimFormProps) => { const { t } = useLingui(); + const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim], + ); + const form = useForm({ resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema), defaultValues: { @@ -142,34 +152,59 @@ export const SubscriptionClaimForm = ({
- {Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => ( - ( - - -
- + {Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map( + ({ key, label, isEnterprise }) => { + const isRestrictedFeature = + isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions - -
-
-
- )} - /> - ))} + return ( + ( + + +
+ + + +
+
+
+ )} + /> + ); + }, + )}
+ + {hasRestrictedEnterpriseFeatures && ( + + + ¹  + Your current license does not include these features.{' '} + + Learn more + + + + )} {formSubmitTrigger} diff --git a/apps/remix/app/components/general/admin-license-card.tsx b/apps/remix/app/components/general/admin-license-card.tsx new file mode 100644 index 000000000..1eb2167e4 --- /dev/null +++ b/apps/remix/app/components/general/admin-license-card.tsx @@ -0,0 +1,212 @@ +import { Trans, useLingui } from '@lingui/react/macro'; +import { + ArrowRightIcon, + CheckCircle2Icon, + KeyRoundIcon, + Loader2Icon, + RefreshCwIcon, + XCircleIcon, +} from 'lucide-react'; +import { DateTime } from 'luxon'; +import { Link, useRevalidator } from 'react-router'; +import { match } from 'ts-pattern'; + +import type { TCachedLicense } from '@documenso/lib/types/license'; +import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; +import { trpc } from '@documenso/trpc/react'; +import { Badge } from '@documenso/ui/primitives/badge'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { CardMetric } from './metric-card'; + +type AdminLicenseCardProps = { + licenseData: TCachedLicense | null; +}; + +export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => { + const { t, i18n } = useLingui(); + + const { license } = licenseData || {}; + + if (!license) { + return ( +
+
+ +
+ +
+
+ +
+ +
+ {licenseData?.requestedLicenseKey ? ( + <> +

+ Invalid License Key +

+

{licenseData.requestedLicenseKey}

+ + ) : ( +

+ No License Configured +

+ )} + + + Learn more + +
+
+
+
+ ); + } + + const enabledFlags = Object.entries(license.flags).filter(([, enabled]) => enabled); + + return ( +
+
+ +
+ +
+
+ +
+ +

+ Documenso License +

+ + {match(license.status) + .with('ACTIVE', () => ( + + + Active + + )) + .with('PAST_DUE', () => ( + + + Past Due + + )) + .with('EXPIRED', () => ( + + + Expired + + )) + .otherwise(() => null)} +
+ +
+
+

+ License +

+

{license.name}

+
+ +
+

+ Expires +

+

+ {i18n.date(license.periodEnd, DateTime.DATE_MED)} +

+
+ +
+

+ License Key +

+

{license.licenseKey}

+
+ +
+

+ Features +

+

+ {enabledFlags.length > 0 ? ( + enabledFlags + .map( + ([flag]) => + SUBSCRIPTION_CLAIM_FEATURE_FLAGS[ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + flag as keyof typeof SUBSCRIPTION_CLAIM_FEATURE_FLAGS + ]?.label || flag, + ) + .join(', ') + ) : ( + No features enabled + )} +

+
+
+
+ ); +}; + +const AdminLicenseResyncButton = () => { + const { t } = useLingui(); + const { toast } = useToast(); + const { revalidate } = useRevalidator(); + + const { mutate: resyncLicense, isPending: isResyncingLicense } = + trpc.admin.license.resync.useMutation({ + onSuccess: async () => { + toast({ + title: t`License synced`, + }); + + await revalidate(); + }, + onError: () => { + toast({ + title: t`Failed to sync license`, + variant: 'destructive', + }); + }, + }); + + return ( + + + + + + + Sync license from server + + + + ); +}; diff --git a/apps/remix/app/components/general/admin-license-status-banner.tsx b/apps/remix/app/components/general/admin-license-status-banner.tsx new file mode 100644 index 000000000..5dff17b09 --- /dev/null +++ b/apps/remix/app/components/general/admin-license-status-banner.tsx @@ -0,0 +1,78 @@ +import { Trans } from '@lingui/react/macro'; +import { AlertTriangleIcon, KeyRoundIcon } from 'lucide-react'; +import { Link } from 'react-router'; +import { match } from 'ts-pattern'; + +import type { TCachedLicense } from '@documenso/lib/types/license'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; + +export type AdminLicenseStatusBannerProps = { + license: TCachedLicense | null; +}; + +export const AdminLicenseStatusBanner = ({ license }: AdminLicenseStatusBannerProps) => { + const licenseStatus = license?.derivedStatus; + + if (!license || licenseStatus === 'ACTIVE' || licenseStatus === 'NOT_FOUND') { + return null; + } + + return ( +
+
+
+ + + {match(licenseStatus) + .with('PAST_DUE', () => ( + + License Payment Overdue - Please update your payment to avoid service disruptions. + + )) + .with('EXPIRED', () => ( + + License Expired - Please renew your license to continue using enterprise features. + + )) + .with('UNAUTHORIZED', () => + license ? ( + + Invalid License Type - Your Documenso instance is using features that are not part + of your license. + + ) : ( + + Missing License - Your Documenso instance is using features that require a + license. + + ), + ) + .otherwise(() => null)} +
+ + +
+
+ ); +}; diff --git a/apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx b/apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx index 3fbb9d424..b8fba1f7a 100644 --- a/apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx +++ b/apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx @@ -1,10 +1,13 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans, useLingui as useLinguiMacro } from '@lingui/react/macro'; import { RecipientRole } from '@prisma/client'; import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients'; +import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients'; import { trpc } from '@documenso/trpc/react'; import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect'; +import { useToast } from '@documenso/ui/primitives/use-toast'; type DefaultRecipientsMultiSelectComboboxProps = { listValues: TDefaultRecipient[]; @@ -20,6 +23,8 @@ export const DefaultRecipientsMultiSelectCombobox = ({ organisationId, }: DefaultRecipientsMultiSelectComboboxProps) => { const { _ } = useLingui(); + const { t } = useLinguiMacro(); + const { toast } = useToast(); const { data: organisationData, isLoading: isLoadingOrganisation } = trpc.organisation.member.find.useQuery( @@ -60,31 +65,56 @@ export const DefaultRecipientsMultiSelectCombobox = ({ })); const onSelectionChange = (selected: Option[]) => { - const updatedRecipients = selected.map((option) => { - const existingRecipient = listValues.find((r) => r.email === option.value); - const member = members?.find((m) => m.email === option.value); + const invalidEmails = selected.filter( + (option) => !isRecipientEmailValidForSending({ email: option.value }), + ); - return { - email: option.value, - name: member?.name || option.value, - role: existingRecipient?.role ?? RecipientRole.CC, - }; - }); + if (invalidEmails.length > 0) { + toast({ + title: t`Invalid email`, + description: t`"${invalidEmails[0].value}" is not a valid email address.`, + variant: 'destructive', + }); + } + + const updatedRecipients = selected + .filter((option) => !invalidEmails.includes(option)) + .map((option) => { + const existingRecipient = listValues.find((r) => r.email === option.value); + const member = members?.find((m) => m.email === option.value); + + return { + email: option.value, + name: member?.name || option.value, + role: existingRecipient?.role ?? RecipientRole.CC, + }; + }); onChange(updatedRecipients); }; return ( Loading...

: undefined} - emptyIndicator={

No members found

} + creatable + loadingIndicator={ + isLoading ? ( +

+ Loading... +

+ ) : undefined + } + emptyIndicator={ +

+ Type an email address to add a recipient +

+ } /> ); }; diff --git a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx index 77e90eff8..9c8246e6c 100644 --- a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx +++ b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx @@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DownloadIcon } from 'lucide-react'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; +import { base64 } from '@documenso/lib/universal/base64'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -25,36 +27,15 @@ export const DocumentAuditLogDownloadButton = ({ const onDownloadAuditLogsClick = async () => { try { - const { url } = await downloadAuditLogs({ documentId }); + const { data, envelopeTitle } = await downloadAuditLogs({ documentId }); - const iframe = Object.assign(document.createElement('iframe'), { - src: url, + const buffer = new Uint8Array(base64.decode(data)); + const blob = new Blob([buffer], { type: 'application/pdf' }); + + downloadFile({ + data: blob, + filename: `${envelopeTitle} - Audit Logs.pdf`, }); - - Object.assign(iframe.style, { - position: 'fixed', - top: '0', - left: '0', - width: '0', - height: '0', - }); - - const onLoaded = () => { - if (iframe.contentDocument?.readyState === 'complete') { - iframe.contentWindow?.print(); - - iframe.contentWindow?.addEventListener('afterprint', () => { - document.body.removeChild(iframe); - }); - } - }; - - // When the iframe has loaded, print the iframe and remove it from the dom - iframe.addEventListener('load', onLoaded); - - document.body.appendChild(iframe); - - onLoaded(); } catch (error) { console.error(error); diff --git a/apps/remix/app/components/general/document/document-certificate-download-button.tsx b/apps/remix/app/components/general/document/document-certificate-download-button.tsx index 7584fe81b..67bc173c7 100644 --- a/apps/remix/app/components/general/document/document-certificate-download-button.tsx +++ b/apps/remix/app/components/general/document/document-certificate-download-button.tsx @@ -4,6 +4,8 @@ import { Trans } from '@lingui/react/macro'; import type { DocumentStatus } from '@prisma/client'; import { DownloadIcon } from 'lucide-react'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; +import { base64 } from '@documenso/lib/universal/base64'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -29,36 +31,15 @@ export const DocumentCertificateDownloadButton = ({ const onDownloadCertificatesClick = async () => { try { - const { url } = await downloadCertificate({ documentId }); + const { data, envelopeTitle } = await downloadCertificate({ documentId }); - const iframe = Object.assign(document.createElement('iframe'), { - src: url, + const buffer = new Uint8Array(base64.decode(data)); + const blob = new Blob([buffer], { type: 'application/pdf' }); + + downloadFile({ + data: blob, + filename: `${envelopeTitle} - Certificate.pdf`, }); - - Object.assign(iframe.style, { - position: 'fixed', - top: '0', - left: '0', - width: '0', - height: '0', - }); - - const onLoaded = () => { - if (iframe.contentDocument?.readyState === 'complete') { - iframe.contentWindow?.print(); - - iframe.contentWindow?.addEventListener('afterprint', () => { - document.body.removeChild(iframe); - }); - } - }; - - // When the iframe has loaded, print the iframe and remove it from the dom - iframe.addEventListener('load', onLoaded); - - document.body.appendChild(iframe); - - onLoaded(); } catch (error) { console.error(error); diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx index ab83b3189..4cb544627 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -594,8 +594,12 @@ export const EnvelopeEditorRecipientForm = () => {
- Recipients - Add recipients to your document + + Recipients + + + Add recipients to your document +
diff --git a/apps/remix/app/components/general/metric-card.tsx b/apps/remix/app/components/general/metric-card.tsx index 67ecf17aa..559b049f9 100644 --- a/apps/remix/app/components/general/metric-card.tsx +++ b/apps/remix/app/components/general/metric-card.tsx @@ -5,15 +5,16 @@ import { cn } from '@documenso/ui/lib/utils'; export type CardMetricProps = { icon?: LucideIcon; title: string; - value: string | number; + value?: string | number; className?: string; + children?: React.ReactNode; }; -export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricProps) => { +export const CardMetric = ({ icon: Icon, title, value, className, children }: CardMetricProps) => { return (
@@ -21,7 +22,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
{Icon && (
- +
)} @@ -30,9 +31,11 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
-

- {typeof value === 'number' ? value.toLocaleString('en-US') : value} -

+ {children || ( +

+ {typeof value === 'number' ? value.toLocaleString('en-US') : value} +

+ )}
); diff --git a/apps/remix/app/components/tables/admin-claims-table.tsx b/apps/remix/app/components/tables/admin-claims-table.tsx index 425472d0a..cf6d82aac 100644 --- a/apps/remix/app/components/tables/admin-claims-table.tsx +++ b/apps/remix/app/components/tables/admin-claims-table.tsx @@ -6,6 +6,7 @@ import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react'; import { Link, useSearchParams } from 'react-router'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import type { TLicenseClaim } from '@documenso/lib/types/license'; import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; import { trpc } from '@documenso/trpc/react'; @@ -27,7 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { ClaimDeleteDialog } from '../dialogs/claim-delete-dialog'; import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog'; -export const AdminClaimsTable = () => { +type AdminClaimsTableProps = { + licenseFlags?: TLicenseClaim; +}; + +export const AdminClaimsTable = ({ licenseFlags }: AdminClaimsTableProps) => { const { t } = useLingui(); const { toast } = useToast(); @@ -97,11 +102,11 @@ export const AdminClaimsTable = () => { ); if (flags.length === 0) { - return

{t`None`}

; + return

{t`None`}

; } return ( -
    +
      {flags.map(({ key, label }) => (
    • {label}
    • ))} @@ -114,7 +119,7 @@ export const AdminClaimsTable = () => { cell: ({ row }) => ( - + @@ -124,6 +129,7 @@ export const AdminClaimsTable = () => { e.preventDefault()}>
      diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx index ebd4574c5..a9da6ac2d 100644 --- a/apps/remix/app/components/tables/documents-table.tsx +++ b/apps/remix/app/components/tables/documents-table.tsx @@ -12,7 +12,8 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types'; -import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { Skeleton } from '@documenso/ui/primitives/skeleton'; @@ -30,6 +31,9 @@ export type DocumentsTableProps = { isLoading?: boolean; isLoadingError?: boolean; onMoveDocument?: (documentId: number) => void; + enableSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: (selection: RowSelectionState) => void; }; type DocumentsTableRow = TFindDocumentsResponse['data'][number]; @@ -39,6 +43,9 @@ export const DocumentsTable = ({ isLoading, isLoadingError, onMoveDocument, + enableSelection, + rowSelection, + onRowSelectionChange, }: DocumentsTableProps) => { const { _, i18n } = useLingui(); @@ -48,7 +55,34 @@ export const DocumentsTable = ({ const updateSearchParams = useUpdateSearchParams(); const columns = useMemo(() => { - return [ + const cols: DataTableColumnDef[] = []; + + if (enableSelection) { + cols.push({ + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={_(msg`Select all`)} + onClick={(e) => e.stopPropagation()} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={_(msg`Select row`)} + onClick={(e) => e.stopPropagation()} + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }); + } + + cols.push( { header: _(msg`Created`), accessorKey: 'createdAt', @@ -93,8 +127,10 @@ export const DocumentsTable = ({
      ), }, - ] satisfies DataTableColumnDef[]; - }, [team, onMoveDocument]); + ); + + return cols; + }, [team, onMoveDocument, enableSelection]); const onPaginationChange = (page: number, perPage: number) => { startTransition(() => { @@ -132,6 +168,11 @@ export const DocumentsTable = ({ rows: 5, component: ( <> + {enableSelection && ( + + + + )} @@ -152,13 +193,17 @@ export const DocumentsTable = ({ ), }} + enableRowSelection={enableSelection} + rowSelection={rowSelection} + onRowSelectionChange={onRowSelectionChange} + getRowId={(row) => row.envelopeId} > {(table) => } {isPending && ( -
      - +
      +
      )}
      diff --git a/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx new file mode 100644 index 000000000..84ee783b7 --- /dev/null +++ b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -0,0 +1,55 @@ +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react'; + +import { Button } from '@documenso/ui/primitives/button'; + +export type EnvelopesTableBulkActionBarProps = { + selectedCount: number; + onMoveClick: () => void; + onDeleteClick: () => void; + onClearSelection: () => void; +}; + +export const EnvelopesTableBulkActionBar = ({ + selectedCount, + onMoveClick, + onDeleteClick, + onClearSelection, +}: EnvelopesTableBulkActionBarProps) => { + const { t } = useLingui(); + + if (selectedCount === 0) { + return null; + } + + return ( +
      + + {selectedCount} selected + + +
      + + + + + + +
      + ); +}; diff --git a/apps/remix/app/components/tables/templates-table.tsx b/apps/remix/app/components/tables/templates-table.tsx index 59160ad89..df7ef6dc1 100644 --- a/apps/remix/app/components/tables/templates-table.tsx +++ b/apps/remix/app/components/tables/templates-table.tsx @@ -12,7 +12,8 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import type { TFindTemplatesResponse } from '@documenso/trpc/server/template-router/schema'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; -import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { Skeleton } from '@documenso/ui/primitives/skeleton'; @@ -32,6 +33,9 @@ type TemplatesTableProps = { isLoadingError?: boolean; documentRootPath: string; templateRootPath: string; + enableSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: (selection: RowSelectionState) => void; }; type TemplatesTableRow = TFindTemplatesResponse['data'][number]; @@ -42,6 +46,9 @@ export const TemplatesTable = ({ isLoadingError, documentRootPath, templateRootPath, + enableSelection, + rowSelection, + onRowSelectionChange, }: TemplatesTableProps) => { const { _, i18n } = useLingui(); const { remaining } = useLimits(); @@ -60,7 +67,34 @@ export const TemplatesTable = ({ }; const columns = useMemo(() => { - return [ + const cols: DataTableColumnDef[] = []; + + if (enableSelection) { + cols.push({ + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={_(msg`Select all`)} + onClick={(e) => e.stopPropagation()} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={_(msg`Select row`)} + onClick={(e) => e.stopPropagation()} + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }); + } + + cols.push( { header: _(msg`Created`), accessorKey: 'createdAt', @@ -86,8 +120,8 @@ export const TemplatesTable = ({ - -
        + +
        • @@ -176,8 +210,10 @@ export const TemplatesTable = ({ ); }, }, - ] satisfies DataTableColumnDef[]; - }, [documentRootPath, team?.id, templateRootPath]); + ); + + return cols; + }, [documentRootPath, team?.id, templateRootPath, enableSelection]); const onPaginationChange = (page: number, perPage: number) => { startTransition(() => { @@ -224,6 +260,10 @@ export const TemplatesTable = ({ currentPage={results.currentPage} totalPages={results.totalPages} onPaginationChange={onPaginationChange} + enableRowSelection={enableSelection} + rowSelection={rowSelection} + onRowSelectionChange={onRowSelectionChange} + getRowId={(row) => row.envelopeId} error={{ enable: isLoadingError || false, }} @@ -232,6 +272,11 @@ export const TemplatesTable = ({ rows: 5, component: ( <> + {enableSelection && ( + + + + )} diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index 1b5ae1acd..76db197e0 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -7,7 +7,6 @@ import { data, isRouteErrorResponse, useLoaderData, - useLocation, } from 'react-router'; import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; @@ -87,8 +86,6 @@ export async function loader({ request }: Route.LoaderArgs) { export function Layout({ children }: { children: React.ReactNode }) { const { theme } = useLoaderData() || {}; - const location = useLocation(); - return ( {children} @@ -129,6 +126,18 @@ export function LayoutContent({ children }: { children: React.ReactNode }) { + {/* Global license banner currently disabled. Need to wait until after a few releases. */} + {/* {licenseStatus === '?' && ( +
          +
          +
          + + This is an expired license instance of Documenso +
          +
          +
          + )} */} + diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx index 33ef772f5..8757372b1 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx @@ -11,25 +11,37 @@ import { import { Link, Outlet, redirect, useLocation } from 'react-router'; import { getSession } from '@documenso/auth/server/lib/utils/get-session'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { isAdmin } from '@documenso/lib/utils/is-admin'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner'; + import type { Route } from './+types/_layout'; export async function loader({ request }: Route.LoaderArgs) { const { user } = await getSession(request); + const license = await LicenseClient.getInstance()?.getCachedLicense(); + if (!user || !isAdmin(user)) { throw redirect('/'); } + + return { + license: license || null, + }; } -export default function AdminLayout() { +export default function AdminLayout({ loaderData }: Route.ComponentProps) { + const { license } = loaderData; const { pathname } = useLocation(); return (
          + +

          Admin Panel

          diff --git a/apps/remix/app/routes/_authenticated+/admin+/claims.tsx b/apps/remix/app/routes/_authenticated+/admin+/claims.tsx index c9ed9d2e0..2028116cb 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/claims.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/claims.tsx @@ -4,13 +4,26 @@ import { useLingui } from '@lingui/react/macro'; import { useLocation, useSearchParams } from 'react-router'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { Input } from '@documenso/ui/primitives/input'; import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog'; import { SettingsHeader } from '~/components/general/settings-header'; import { AdminClaimsTable } from '~/components/tables/admin-claims-table'; -export default function Claims() { +import type { Route } from './+types/claims'; + +export async function loader() { + const licenseData = await LicenseClient.getInstance()?.getCachedLicense(); + + return { + licenseFlags: licenseData?.license?.flags, + }; +} + +export default function Claims({ loaderData }: Route.ComponentProps) { + const { licenseFlags } = loaderData; + const { t } = useLingui(); const [searchParams, setSearchParams] = useSearchParams(); @@ -47,7 +60,7 @@ export default function Claims() { subtitle={t`Manage all subscription claims`} hideDivider > - +
          @@ -58,7 +71,7 @@ export default function Claims() { className="mb-4" /> - +
          ); diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index 58a8449c7..159f9dd76 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -12,6 +12,8 @@ import type { z } from 'zod'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing'; import { AppError } from '@documenso/lib/errors/app-error'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; +import type { TLicenseClaim } from '@documenso/lib/types/license'; import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; import { trpc } from '@documenso/trpc/react'; import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types'; @@ -40,7 +42,20 @@ import { SettingsHeader } from '~/components/general/settings-header'; import type { Route } from './+types/organisations.$id'; -export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) { +export async function loader() { + const licenseData = await LicenseClient.getInstance()?.getCachedLicense(); + + return { + licenseFlags: licenseData?.license?.flags, + }; +} + +export default function OrganisationGroupSettingsPage({ + params, + loaderData, +}: Route.ComponentProps) { + const { licenseFlags } = loaderData; + const { t } = useLingui(); const { toast } = useToast(); @@ -129,7 +144,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen if (isLoadingOrganisation) { return (
          - +
          ); } @@ -239,7 +254,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen )} - +
          @@ -278,6 +293,7 @@ type TUpdateGenericOrganisationDataFormSchema = z.infer< type OrganisationAdminFormOptions = { organisation: TGetAdminOrganisationResponse; + licenseFlags?: TLicenseClaim; }; const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => { @@ -349,7 +365,7 @@ const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOpt {!form.formState.errors.url && ( - + {field.value ? ( `${NEXT_PUBLIC_WEBAPP_URL()}/o/${field.value}` ) : ( @@ -381,12 +397,17 @@ const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSche type TUpdateOrganisationBillingFormSchema = z.infer; -const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => { +const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdminFormOptions) => { const { toast } = useToast(); const { t } = useLingui(); const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation(); + const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim], + ); + const form = useForm({ resolver: zodResolver(ZUpdateOrganisationBillingFormSchema), defaultValues: { @@ -440,7 +461,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) = - +

          Inherited subscription claim @@ -493,7 +514,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) = {`https://dashboard.stripe.com/customers/${field.value}`} @@ -582,34 +603,57 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
          - {Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => ( - ( - - -
          - + {Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => { + const isRestrictedFeature = + isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions - -
          -
          -
          - )} - /> - ))} + return ( + ( + + +
          + + + +
          +
          +
          + )} + /> + ); + })}
          + + {hasRestrictedEnterpriseFeatures && ( + + + ¹  + Your current license does not include these features.{' '} + + Learn more + + + + )}

          diff --git a/apps/remix/app/routes/_authenticated+/admin+/stats.tsx b/apps/remix/app/routes/_authenticated+/admin+/stats.tsx index bc43d1942..c99cfd0fa 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/stats.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/stats.tsx @@ -23,8 +23,10 @@ import { getUserWithSignedDocumentMonthlyGrowth, getUsersCount, } from '@documenso/lib/server-only/admin/get-users-stats'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion'; +import { AdminLicenseCard } from '~/components/general/admin-license-card'; import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts'; import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart'; import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents'; @@ -42,6 +44,7 @@ export async function loader() { signerConversionMonthly, monthlyUsersWithDocuments, monthlyActiveUsers, + licenseData, ] = await Promise.all([ getUsersCount(), getOrganisationsWithSubscriptionsCount(), @@ -50,6 +53,7 @@ export async function loader() { getSignerConversionMonthly(), getUserWithSignedDocumentMonthlyGrowth(), getMonthlyActiveUsers(), + LicenseClient.getInstance()?.getCachedLicense(), ]); return { @@ -60,6 +64,7 @@ export async function loader() { signerConversionMonthly, monthlyUsersWithDocuments, monthlyActiveUsers, + licenseData: licenseData || null, }; } @@ -74,6 +79,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) { signerConversionMonthly, monthlyUsersWithDocuments, monthlyActiveUsers, + licenseData, } = loaderData; return ( @@ -94,6 +100,10 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
          +
          + +
          +

          diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx index 0c6c37339..6ecff1c13 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -179,7 +179,9 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) ))}
          -

          Recipients

          +

          + Recipients +

            {recipients.map((recipient) => (
          • diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index 58e80af6e..fd271aafe 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -16,9 +16,12 @@ import { trpc } from '@documenso/trpc/react'; import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types'; import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import type { RowSelectionState } from '@documenso/ui/primitives/data-table'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog'; +import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog'; +import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog'; import { DocumentSearch } from '~/components/general/document/document-search'; import { DocumentStatus } from '~/components/general/document/document-status'; import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper'; @@ -27,6 +30,7 @@ import { PeriodSelector } from '~/components/general/period-selector'; import { DocumentsTable } from '~/components/tables/documents-table'; import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state'; import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter'; +import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar'; import { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; @@ -54,6 +58,14 @@ export default function DocumentsPage() { const [isMovingDocument, setIsMovingDocument] = useState(false); const [documentToMove, setDocumentToMove] = useState(null); + const [rowSelection, setRowSelection] = useState({}); + const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + + const selectedEnvelopeIds = useMemo(() => { + return Object.keys(rowSelection).filter((id) => rowSelection[id]); + }, [rowSelection]); + const [stats, setStats] = useState({ [ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.PENDING]: 0, @@ -109,6 +121,11 @@ export default function DocumentsPage() { } }, [data?.stats]); + // Clear selection when navigation or filters change + useEffect(() => { + setRowSelection({}); + }, [folderId, findDocumentSearchParams]); + return (
            @@ -116,9 +133,9 @@ export default function DocumentsPage() {
            - + {team.avatarImageId && } - + {team.name.slice(0, 1)} @@ -148,7 +165,7 @@ export default function DocumentsPage() { .map((value) => ( @@ -190,6 +207,9 @@ export default function DocumentsPage() { setDocumentToMove(documentId); setIsMovingDocument(true); }} + enableSelection + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} /> )}
            @@ -209,6 +229,30 @@ export default function DocumentsPage() { }} /> )} + + setIsBulkMoveDialogOpen(true)} + onDeleteClick={() => setIsBulkDeleteDialogOpen(true)} + onClearSelection={() => setRowSelection({})} + /> + + setRowSelection({})} + /> + + setRowSelection({})} + />
            ); diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx index c97df2739..955d5194f 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx @@ -1,3 +1,5 @@ +import { useEffect, useMemo, useState } from 'react'; + import { Trans } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; import { Bird } from 'lucide-react'; @@ -8,9 +10,13 @@ import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar'; +import type { RowSelectionState } from '@documenso/ui/primitives/data-table'; +import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog'; +import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog'; import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper'; import { FolderGrid } from '~/components/general/folder/folder-grid'; +import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar'; import { TemplatesTable } from '~/components/tables/templates-table'; import { useCurrentTeam } from '~/providers/team'; import { appMetaTags } from '~/utils/meta'; @@ -28,6 +34,14 @@ export default function TemplatesPage() { const page = Number(searchParams.get('page')) || 1; const perPage = Number(searchParams.get('perPage')) || 10; + const [rowSelection, setRowSelection] = useState({}); + const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + + const selectedEnvelopeIds = useMemo(() => { + return Object.keys(rowSelection).filter((id) => rowSelection[id]); + }, [rowSelection]); + const documentRootPath = formatDocumentsPath(team.url); const templateRootPath = formatTemplatesPath(team.url); @@ -37,6 +51,11 @@ export default function TemplatesPage() { folderId, }); + // Clear selection when navigation or filters change + useEffect(() => { + setRowSelection({}); + }, [folderId, page, perPage]); + return (
            @@ -44,9 +63,9 @@ export default function TemplatesPage() {
            - + {team.avatarImageId && } - + {team.name.slice(0, 1)} @@ -58,7 +77,7 @@ export default function TemplatesPage() {
            {data && data.count === 0 ? ( -
            +
            @@ -81,10 +100,37 @@ export default function TemplatesPage() { isLoadingError={isLoadingError} documentRootPath={documentRootPath} templateRootPath={templateRootPath} + enableSelection + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} /> )}
            + + setIsBulkMoveDialogOpen(true)} + onDeleteClick={() => setIsBulkDeleteDialogOpen(true)} + onClearSelection={() => setRowSelection({})} + /> + + setRowSelection({})} + /> + + setRowSelection({})} + />
            ); diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx index 126931a49..f316af405 100644 --- a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx +++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx @@ -1,5 +1,6 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; import { EnvelopeType, FieldType, SigningStatus } from '@prisma/client'; import { DateTime } from 'luxon'; import { redirect } from 'react-router'; @@ -290,7 +291,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)

            ) : ( -

            N/A

            +

            + N/A +

            )}

            diff --git a/apps/remix/package.json b/apps/remix/package.json index e7e0fa184..d288b9dc8 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -106,5 +106,5 @@ "vite-plugin-babel-macros": "^1.0.6", "vite-tsconfig-paths": "^5.1.4" }, - "version": "2.5.0" + "version": "2.6.0" } diff --git a/apps/remix/server/main.js b/apps/remix/server/main.js index e0d54f65c..15fa84e12 100644 --- a/apps/remix/server/main.js +++ b/apps/remix/server/main.js @@ -17,7 +17,7 @@ server.use( serveStatic({ root: 'build/client', onFound: (path, c) => { - if (path.startsWith('./build/client/assets')) { + if (path.startsWith('build/client/assets')) { // Hard cache assets with hashed file names. c.header('Cache-Control', 'public, immutable, max-age=31536000'); } else { diff --git a/apps/remix/server/router.ts b/apps/remix/server/router.ts index bf091a955..12ac294ce 100644 --- a/apps/remix/server/router.ts +++ b/apps/remix/server/router.ts @@ -10,6 +10,7 @@ import { tsRestHonoApp } from '@documenso/api/hono'; import { auth } from '@documenso/auth/server'; import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app'; import { jobsClient } from '@documenso/lib/jobs/client'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client'; import { getIpAddress } from '@documenso/lib/universal/get-ip-address'; import { env } from '@documenso/lib/utils/env'; @@ -140,4 +141,7 @@ if (env('NODE_ENV') !== 'development') { void TelemetryClient.start(); } +// Start license client to verify license on startup. +void LicenseClient.start(); + export default app; diff --git a/docker/start.sh b/docker/start.sh index 56b225e14..2598eae83 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -11,7 +11,7 @@ CERT_PATH="${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}" if [ -f "$CERT_PATH" ] && [ -r "$CERT_PATH" ]; then printf "✅ Certificate file found and readable - document signing is ready!\n" else - printf "⚠️ Certificate not found or not readable\n" + printf "⚠️ Certificate not found or not readable\n" printf "💡 Tip: Documenso will still start, but document signing will be unavailable\n" printf "🔧 Check: http://localhost:3000/api/certificate-status for detailed status\n" fi diff --git a/package-lock.json b/package-lock.json index 516ee4b01..8cd8b4d01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "2.5.0", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "2.5.0", + "version": "2.6.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -15,11 +15,10 @@ "dependencies": { "@ai-sdk/google-vertex": "3.0.81", "@documenso/prisma": "*", - "@libpdf/core": "^0.2.2", + "@libpdf/core": "^0.2.5", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "ai": "^5.0.104", - "inngest-cli": "^1.13.7", "luxon": "^3.7.2", "patch-package": "^8.0.1", "posthog-node": "4.18.0", @@ -42,6 +41,7 @@ "dotenv-cli": "^11.0.0", "eslint": "^8.57.0", "husky": "^9.1.7", + "inngest-cli": "^1.16.1", "lint-staged": "^16.2.7", "nanoid": "^5.1.6", "nodemailer": "^7.0.10", @@ -53,11 +53,10 @@ "prisma": "^6.19.0", "prisma-extension-kysely": "^3.0.0", "prisma-json-types-generator": "^3.6.2", - "prisma-kysely": "^2.2.1", + "prisma-kysely": "^2.3.0", "rimraf": "^6.1.2", "superjson": "^2.2.5", "syncpack": "^14.0.0-alpha.27", - "trpc-to-openapi": "2.4.0", "turbo": "^1.13.4", "vite": "^7.2.4", "vite-plugin-static-copy": "^3.1.4", @@ -109,7 +108,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "2.5.0", + "version": "2.6.0", "dependencies": { "@cantoo/pdf-lib": "^2.5.3", "@documenso/api": "*", @@ -4026,6 +4025,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -4155,9 +4167,9 @@ "license": "MIT" }, "node_modules/@libpdf/core": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.2.2.tgz", - "integrity": "sha512-qxYHUirZc4YSxTYkUVtLHqM9ypC9iKPOpHYfpDIUYAZyDGgcHh4SOwhkInHY/Q51TBajXRv6ZZd9fjSPvoQZnw==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.2.5.tgz", + "integrity": "sha512-+oTpNRkEdL1kVmeJr6qz2wf0yqJx5FmUVN2u0kDuX81wvxyzYOlMjmFD8qbbJqyYiNZp0J7IAcW6VsZr+MW1Uw==", "license": "MIT", "dependencies": { "@noble/ciphers": "^2.1.1", @@ -17745,6 +17757,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0" @@ -18988,12 +19001,13 @@ } }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/ci-info": { @@ -23886,36 +23900,6 @@ "node": ">=14.14" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -24650,19 +24634,19 @@ } }, "node_modules/h3": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.1.tgz", - "integrity": "sha512-+ORaOBttdUm1E2Uu/obAyCguiI7MbBvsLTndc3gyK3zU+SYLoZXlyCP9Xgy0gikkGufFLTZXCXD6+4BsufnmHA==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.5.tgz", + "integrity": "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==", "license": "MIT", "dependencies": { "cookie-es": "^1.2.2", - "crossws": "^0.3.3", + "crossws": "^0.3.5", "defu": "^6.1.4", - "destr": "^2.0.3", + "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", - "node-mock-http": "^1.0.0", + "node-mock-http": "^1.0.4", "radix3": "^1.1.2", - "ufo": "^1.5.4", + "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, @@ -25494,16 +25478,17 @@ } }, "node_modules/inngest-cli": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/inngest-cli/-/inngest-cli-1.13.7.tgz", - "integrity": "sha512-u3gbUsjsTHfh5quKE3C3sw/NENFPjiWG602SNZya0Eto44U9KGVT0nHp9UoZF5qiG7X83fJqqNEjDKN4FB9JaQ==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/inngest-cli/-/inngest-cli-1.16.1.tgz", + "integrity": "sha512-B5Z2vgidh6HM7HVsQFZSffJpnTKB0Rm20NDL1qOjBtF8ZZgun+mJNjUYAcqOU4l8SVVEG0nfPnphsWPLZHXdsA==", + "dev": true, "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "adm-zip": "^0.5.10", "debug": "^4.3.4", "node-fetch": "^2.6.7", - "tar": "^6.2.1" + "tar": "^7.5.3" }, "bin": { "inngest": "bin/inngest", @@ -27842,9 +27827,9 @@ } }, "node_modules/mermaid": { - "version": "11.12.1", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.1.tgz", - "integrity": "sha512-UlIZrRariB11TY1RtTgUWp65tphtBv4CSq7vyS2ZZ2TgoMjs2nloq+wFqxiwcxlhHUvs7DPGgMjs2aeQxz5h9g==", + "version": "11.12.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz", + "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", @@ -28765,54 +28750,24 @@ } }, "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, "license": "MIT", "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" + "minipass": "^7.1.2" }, "engines": { - "node": ">= 8" + "node": ">= 18" } }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/mj-context-menu": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-0.6.1.tgz", "integrity": "sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==", "license": "Apache-2.0" }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -29443,9 +29398,9 @@ } }, "node_modules/node-mock-http": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.3.tgz", - "integrity": "sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", "license": "MIT" }, "node_modules/node-releases": { @@ -31354,51 +31309,22 @@ } }, "node_modules/prisma-kysely": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-2.2.1.tgz", - "integrity": "sha512-hxue1/ZzyBJ8qx6bSBNUjF+A+tl78PM5Sg/wIAIph9hxwdiGcWTr8JPQveva4zvA57bwRJhAJjDvdy8MK4UNuA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-2.3.0.tgz", + "integrity": "sha512-/+VF2t2DlY+t/27hhyH5ULWxBAAsMBPgOo8Ltq7uXpBz49lUf4XuO1ff9HV0l7J3aDojG+YyczEvY0og9uRqsw==", "license": "MIT", "dependencies": { - "@mrleebo/prisma-ast": "^0.13.0", - "@prisma/generator-helper": "6.16.2", - "@prisma/internals": "6.16.2", - "typescript": "^5.9.2", - "zod": "^4.1.5" + "@mrleebo/prisma-ast": "^0.13.1", + "@prisma/generator-helper": "^6.2.0", + "@prisma/internals": "^6.2.0", + "typescript": "^5.9.3", + "zod": "^4.3.5" }, "bin": { "prisma-kysely": "dist/bin.js" }, "peerDependencies": { - "prisma": "~6.16" - } - }, - "node_modules/prisma-kysely/node_modules/@prisma/debug": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.2.tgz", - "integrity": "sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==", - "license": "Apache-2.0" - }, - "node_modules/prisma-kysely/node_modules/@prisma/dmmf": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.16.2.tgz", - "integrity": "sha512-o9ztgdbj2KZXl6DL+oP56TTC0poTLPns9/MeU761b49E1IQ/fd0jwdov1bidlNOiwio8Nsou23xNrYE/db10aA==", - "license": "Apache-2.0" - }, - "node_modules/prisma-kysely/node_modules/@prisma/generator": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.16.2.tgz", - "integrity": "sha512-7bwRmtMIgfe1rUynh1p9VlmYvEiidbRO6aBphPBS6YGEGSvNe8+QExbRpsqFlFBvIX76BhZCxuEj7ZwALMYRKQ==", - "license": "Apache-2.0" - }, - "node_modules/prisma-kysely/node_modules/@prisma/generator-helper": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.16.2.tgz", - "integrity": "sha512-8tVnWM8ETJNrvI5CT9eKCW23+aPLNkidC+g9NJn7ghXm60Q7GGlLX5tmvn5dE8tXvs/FSX3MN7KNmNJpOr89Hw==", - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "6.16.2", - "@prisma/dmmf": "6.16.2", - "@prisma/generator": "6.16.2" + "prisma": ">=6.2.0 <7.0.0" } }, "node_modules/prismjs": { @@ -34891,36 +34817,31 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "license": "ISC", + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", + "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "engines": { - "node": ">=8" + "node": ">=18" } }, "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } }, "node_modules/teeny-request": { "version": "10.1.0", @@ -35197,9 +35118,9 @@ } }, "node_modules/trpc-to-openapi": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/trpc-to-openapi/-/trpc-to-openapi-2.4.0.tgz", - "integrity": "sha512-B6xrwOC3Ab0q1BWD/QbJzK4OUpCLoT02hAzshSUXEuIZGcJZkMG/OJ4/3gd20dyr8aI+CrFirpWKRIo7JmHbMQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/trpc-to-openapi/-/trpc-to-openapi-2.1.5.tgz", + "integrity": "sha512-cX6AlrIBEK0FJe8XJ0wFCroXFqTRpF3J5v4vU5D0Tq7YI1vj8oOIY50iaJX4Gv1PZTA6Dm9DMF7R1MUtlPZ4dQ==", "license": "MIT", "workspaces": [ ".", @@ -35212,17 +35133,17 @@ "examples/with-nuxtjs" ], "dependencies": { - "co-body": "6.2.0", - "h3": "1.15.1", + "co-body": "^6.1.0", + "h3": "^1.6.4", "openapi3-ts": "4.4.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.6.1" }, "peerDependencies": { - "@trpc/server": "^11.1.0", + "@trpc/server": "^11.0.0-rc.648", "zod": "^3.23.8", - "zod-openapi": "4.2.4" + "zod-openapi": "^4.1.0" } }, "node_modules/trpc-to-openapi/node_modules/@rollup/rollup-linux-x64-gnu": { @@ -35656,9 +35577,9 @@ } }, "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, "node_modules/uint8array-extras": { @@ -37466,7 +37387,7 @@ "prisma": "^6.19.0", "prisma-extension-kysely": "^3.0.0", "prisma-json-types-generator": "^3.6.2", - "prisma-kysely": "^2.2.1", + "prisma-kysely": "^2.3.0", "ts-pattern": "^5.9.0", "zod": "^3.25.76", "zod-prisma-types": "3.3.5" @@ -37521,7 +37442,7 @@ "formidable": "^3.5.4", "luxon": "^3.7.2", "superjson": "^2.2.5", - "trpc-to-openapi": "2.4.0", + "trpc-to-openapi": "^2.1.5", "ts-pattern": "^5.9.0", "zod": "^3.25.76", "zod-form-data": "^2.0.8", diff --git a/package.json b/package.json index fc6635969..3caf4d260 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "apps/*", "packages/*" ], - "version": "2.5.0", + "version": "2.6.0", "scripts": { "postinstall": "patch-package", "build": "turbo run build", @@ -61,6 +61,7 @@ "dotenv-cli": "^11.0.0", "eslint": "^8.57.0", "husky": "^9.1.7", + "inngest-cli": "^1.16.1", "lint-staged": "^16.2.7", "nanoid": "^5.1.6", "nodemailer": "^7.0.10", @@ -72,11 +73,10 @@ "prisma": "^6.19.0", "prisma-extension-kysely": "^3.0.0", "prisma-json-types-generator": "^3.6.2", - "prisma-kysely": "^2.2.1", + "prisma-kysely": "^2.3.0", "rimraf": "^6.1.2", "superjson": "^2.2.5", "syncpack": "^14.0.0-alpha.27", - "trpc-to-openapi": "2.4.0", "turbo": "^1.13.4", "vite": "^7.2.4", "vite-plugin-static-copy": "^3.1.4", @@ -86,11 +86,10 @@ "dependencies": { "@ai-sdk/google-vertex": "3.0.81", "@documenso/prisma": "*", - "@libpdf/core": "^0.2.2", + "@libpdf/core": "^0.2.5", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "ai": "^5.0.104", - "inngest-cli": "^1.13.7", "luxon": "^3.7.2", "patch-package": "^8.0.1", "posthog-node": "4.18.0", diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts index 50f096498..172c9833f 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -43,6 +43,9 @@ import { const c = initContract(); +const deprecatedDescription = + 'This endpoint is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api.'; + export const ApiContractV1 = c.router( { getDocuments: { @@ -55,6 +58,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Get all documents', + deprecated: true, + description: deprecatedDescription, }, getDocument: { @@ -66,6 +71,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Get a single document', + deprecated: true, + description: deprecatedDescription, }, downloadSignedDocument: { @@ -78,6 +85,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Download a signed document when the storage transport is S3', + deprecated: true, + description: deprecatedDescription, }, createDocument: { @@ -90,6 +99,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Upload a new document and get a presigned URL', + deprecated: true, + description: deprecatedDescription, }, createTemplate: { @@ -102,6 +113,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Create a new template and get a presigned URL', + deprecated: true, + description: deprecatedDescription, }, deleteTemplate: { @@ -114,6 +127,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Delete a template', + deprecated: true, + description: deprecatedDescription, }, getTemplate: { @@ -125,6 +140,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Get a single template', + deprecated: true, + description: deprecatedDescription, }, getTemplates: { @@ -137,6 +154,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Get all templates', + deprecated: true, + description: deprecatedDescription, }, createDocumentFromTemplate: { @@ -150,7 +169,7 @@ export const ApiContractV1 = c.router( }, summary: 'Create a new document from an existing template', deprecated: true, - description: `This has been deprecated in favour of "/api/v1/templates/:templateId/generate-document". You may face unpredictable behavior using this endpoint as it is no longer maintained.`, + description: `${deprecatedDescription} \n\nIf you must use the V1 API, use "/api/v1/templates/:templateId/generate-document" instead.`, }, generateDocumentFromTemplate: { @@ -165,8 +184,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Create a new document from an existing template', - description: - 'Create a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.', + deprecated: true, + description: `${deprecatedDescription} \n\nCreate a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.`, }, sendDocument: { @@ -181,9 +200,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Send a document for signing', - // I'm aware this should be in the variable itself, which it is, however it's difficult for users to find in our current UI. - description: - 'Notes\n\n`sendEmail` - Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links. Defaults to true', + deprecated: true, + description: `${deprecatedDescription} \n\nNotes\n\nsendEmail - Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links. Defaults to true`, }, resendDocument: { @@ -198,6 +216,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Re-send a document for signing', + deprecated: true, + description: deprecatedDescription, }, deleteDocument: { @@ -210,6 +230,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Delete a document', + deprecated: true, + description: deprecatedDescription, }, createRecipient: { @@ -224,6 +246,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Create a recipient for a document', + deprecated: true, + description: deprecatedDescription, }, updateRecipient: { @@ -238,6 +262,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Update a recipient for a document', + deprecated: true, + description: deprecatedDescription, }, deleteRecipient: { @@ -252,6 +278,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Delete a recipient from a document', + deprecated: true, + description: deprecatedDescription, }, createField: { @@ -266,6 +294,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Create a field for a document', + deprecated: true, + description: deprecatedDescription, }, updateField: { @@ -280,6 +310,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Update a field for a document', + deprecated: true, + description: deprecatedDescription, }, deleteField: { @@ -294,6 +326,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Delete a field from a document', + deprecated: true, + description: deprecatedDescription, }, }, { diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 4dba574ab..b3d9825c0 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -796,6 +796,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { title: body.title, }, attachments: body.attachments, + formValues: body.formValues, requestMetadata: metadata, }); diff --git a/packages/api/v1/openapi.ts b/packages/api/v1/openapi.ts index 293395b18..43cecb2ac 100644 --- a/packages/api/v1/openapi.ts +++ b/packages/api/v1/openapi.ts @@ -11,7 +11,8 @@ export const OpenAPIV1 = Object.assign( info: { title: 'Documenso API', version: '1.0.0', - description: 'The Documenso API for retrieving, creating, updating and deleting documents.', + description: + 'API V1 is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.', }, servers: [ { diff --git a/packages/app-tests/e2e/api/v2/placeholder-fields-api.spec.ts b/packages/app-tests/e2e/api/v2/placeholder-fields-api.spec.ts new file mode 100644 index 000000000..e25beaef7 --- /dev/null +++ b/packages/app-tests/e2e/api/v2/placeholder-fields-api.spec.ts @@ -0,0 +1,453 @@ +import { PDF, StandardFonts } from '@libpdf/core'; +import type { APIRequestContext } from '@playwright/test'; +import { expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { + TCreateEnvelopePayload, + TCreateEnvelopeResponse, +} from '@documenso/trpc/server/envelope-router/create-envelope.types'; +import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types'; +import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; + +const FIXTURES_DIR = path.join(__dirname, '../../../../assets/fixtures/auto-placement'); + +test.describe.configure({ mode: 'parallel' }); + +test.describe('Placeholder-based field creation', () => { + let user: User, team: Team, token: string; + + test.beforeEach(async () => { + ({ user, team } = await seedUser()); + ({ token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + })); + }); + + const createEnvelopeWithPdf = async ( + request: APIRequestContext, + pdfFilename: string, + ): Promise => { + const pdfPath = path.join(FIXTURES_DIR, pdfFilename); + const pdfData = fs.readFileSync(pdfPath); + + const formData = new FormData(); + + formData.append( + 'payload', + JSON.stringify({ + type: EnvelopeType.DOCUMENT, + title: 'Placeholder Fields Test', + } satisfies TCreateEnvelopePayload), + ); + + formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' })); + + const res = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(res.ok()).toBeTruthy(); + + return res.json(); + }; + + const createEnvelopeItemsWithPdf = async ( + request: APIRequestContext, + envelopeId: string, + pdfFilename: string, + ) => { + const pdfPath = path.join(FIXTURES_DIR, pdfFilename); + const pdfData = fs.readFileSync(pdfPath); + + const formData = new FormData(); + + formData.append('payload', JSON.stringify({ envelopeId })); + formData.append('files', new File([pdfData], pdfFilename, { type: 'application/pdf' })); + + const res = await request.post(`${baseUrl}/envelope/item/create-many`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(res.ok()).toBeTruthy(); + + return res.json(); + }; + + const addRecipient = async (request: APIRequestContext, envelopeId: string) => { + const payload: TCreateEnvelopeRecipientsRequest = { + envelopeId, + data: [ + { + email: user.email, + name: user.name || '', + role: RecipientRole.SIGNER, + accessAuth: [], + actionAuth: [], + }, + ], + }; + + const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, { + headers: { Authorization: `Bearer ${token}` }, + data: payload, + }); + + expect(res.ok()).toBeTruthy(); + }; + + const addRecipients = async ( + request: APIRequestContext, + envelopeId: string, + recipients: TCreateEnvelopeRecipientsRequest['data'], + ) => { + const payload: TCreateEnvelopeRecipientsRequest = { + envelopeId, + data: recipients, + }; + + const res = await request.post(`${baseUrl}/envelope/recipient/create-many`, { + headers: { Authorization: `Bearer ${token}` }, + data: payload, + }); + + expect(res.ok()).toBeTruthy(); + }; + + const getEnvelope = async ( + request: APIRequestContext, + envelopeId: string, + ): Promise => { + const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + expect(res.ok()).toBeTruthy(); + + return res.json(); + }; + + /** + * Create a PDF with the same placeholder appearing multiple times at different locations. + */ + const createPdfWithDuplicatePlaceholders = async (): Promise => { + const pdf = PDF.create(); + const page = pdf.addPage({ size: 'letter' }); + + // Draw the same placeholder text at three different Y positions. + page.drawText('{{initials}}', { x: 50, y: 700, font: StandardFonts.Helvetica, size: 12 }); + page.drawText('{{initials}}', { x: 50, y: 500, font: StandardFonts.Helvetica, size: 12 }); + page.drawText('{{initials}}', { x: 50, y: 300, font: StandardFonts.Helvetica, size: 12 }); + + const bytes = await pdf.save(); + + return Buffer.from(bytes); + }; + + const createEnvelopeWithPdfBuffer = async ( + request: APIRequestContext, + pdfBuffer: Buffer, + filename: string, + ): Promise => { + const formData = new FormData(); + + formData.append( + 'payload', + JSON.stringify({ + type: EnvelopeType.DOCUMENT, + title: 'Placeholder Fields Test', + } satisfies TCreateEnvelopePayload), + ); + + formData.append('files', new File([pdfBuffer], filename, { type: 'application/pdf' })); + + const res = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(res.ok()).toBeTruthy(); + + return res.json(); + }; + + test('should create a field at a placeholder location', async ({ request }) => { + const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf'); + await addRecipient(request, envelope.id); + + const envelopeData = await getEnvelope(request, envelope.id); + const recipientId = envelopeData.recipients[0].id; + + const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + data: [ + { + recipientId, + type: FieldType.SIGNATURE, + placeholder: '{{signature}}', + }, + ], + }, + }); + + expect(createFieldsRes.ok()).toBeTruthy(); + + const fields = await prisma.field.findMany({ + where: { envelopeId: envelope.id }, + }); + + expect(fields).toHaveLength(1); + expect(fields[0].type).toBe(FieldType.SIGNATURE); + + // Verify the field has non-zero position/dimensions resolved from the placeholder. + expect(fields[0].positionX.toNumber()).toBeGreaterThan(0); + expect(fields[0].positionY.toNumber()).toBeGreaterThan(0); + expect(fields[0].width.toNumber()).toBeGreaterThan(0); + expect(fields[0].height.toNumber()).toBeGreaterThan(0); + }); + + test('should override width and height when provided', async ({ request }) => { + const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf'); + await addRecipient(request, envelope.id); + + const envelopeData = await getEnvelope(request, envelope.id); + const recipientId = envelopeData.recipients[0].id; + + const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + data: [ + { + recipientId, + type: FieldType.NAME, + placeholder: '{{name}}', + width: 30, + height: 5, + }, + ], + }, + }); + + expect(createFieldsRes.ok()).toBeTruthy(); + + const fields = await prisma.field.findMany({ + where: { envelopeId: envelope.id }, + }); + + expect(fields).toHaveLength(1); + expect(fields[0].width.toNumber()).toBeCloseTo(30, 1); + expect(fields[0].height.toNumber()).toBeCloseTo(5, 1); + }); + + test('should fail when placeholder text is not found in the PDF', async ({ request }) => { + const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf'); + await addRecipient(request, envelope.id); + + const envelopeData = await getEnvelope(request, envelope.id); + const recipientId = envelopeData.recipients[0].id; + + const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + data: [ + { + recipientId, + type: FieldType.TEXT, + placeholder: '{{nonexistent}}', + }, + ], + }, + }); + + expect(createFieldsRes.ok()).toBeFalsy(); + }); + + test('should create fields using a mix of coordinate and placeholder positioning', async ({ + request, + }) => { + const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf'); + await addRecipient(request, envelope.id); + + const envelopeData = await getEnvelope(request, envelope.id); + const recipientId = envelopeData.recipients[0].id; + + const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + data: [ + { + recipientId, + type: FieldType.SIGNATURE, + placeholder: '{{signature}}', + }, + { + recipientId, + type: FieldType.DATE, + page: 1, + positionX: 10, + positionY: 20, + width: 15, + height: 3, + }, + ], + }, + }); + + expect(createFieldsRes.ok()).toBeTruthy(); + + const fields = await prisma.field.findMany({ + where: { envelopeId: envelope.id }, + orderBy: { type: 'asc' }, + }); + + expect(fields).toHaveLength(2); + + const dateField = fields.find((f) => f.type === FieldType.DATE); + const signatureField = fields.find((f) => f.type === FieldType.SIGNATURE); + + expect(dateField).toBeDefined(); + expect(dateField!.positionX.toNumber()).toBeCloseTo(10, 1); + expect(dateField!.positionY.toNumber()).toBeCloseTo(20, 1); + + expect(signatureField).toBeDefined(); + expect(signatureField!.positionX.toNumber()).toBeGreaterThan(0); + }); + + test('should create a field only at first occurrence by default', async ({ request }) => { + const pdfBuffer = await createPdfWithDuplicatePlaceholders(); + const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf'); + await addRecipient(request, envelope.id); + + const envelopeData = await getEnvelope(request, envelope.id); + const recipientId = envelopeData.recipients[0].id; + + const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + data: [ + { + recipientId, + type: FieldType.INITIALS, + placeholder: '{{initials}}', + }, + ], + }, + }); + + expect(createFieldsRes.ok()).toBeTruthy(); + + const fields = await prisma.field.findMany({ + where: { envelopeId: envelope.id }, + }); + + // Should only create one field (first occurrence). + expect(fields).toHaveLength(1); + expect(fields[0].type).toBe(FieldType.INITIALS); + }); + + test('should create fields at all occurrences when matchAll is true', async ({ request }) => { + const pdfBuffer = await createPdfWithDuplicatePlaceholders(); + const envelope = await createEnvelopeWithPdfBuffer(request, pdfBuffer, 'duplicates.pdf'); + await addRecipient(request, envelope.id); + + const envelopeData = await getEnvelope(request, envelope.id); + const recipientId = envelopeData.recipients[0].id; + + const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, { + headers: { Authorization: `Bearer ${token}` }, + data: { + envelopeId: envelope.id, + data: [ + { + recipientId, + type: FieldType.INITIALS, + placeholder: '{{initials}}', + matchAll: true, + }, + ], + }, + }); + + expect(createFieldsRes.ok()).toBeTruthy(); + + const fields = await prisma.field.findMany({ + where: { envelopeId: envelope.id }, + orderBy: { positionY: 'asc' }, + }); + + // Should create three fields (one for each occurrence). + expect(fields).toHaveLength(3); + + // All should be INITIALS type. + expect(fields.every((f) => f.type === FieldType.INITIALS)).toBe(true); + + // Verify they're at different Y positions. + const yPositions = fields.map((f) => f.positionY.toNumber()); + const uniqueYPositions = new Set(yPositions); + + expect(uniqueYPositions.size).toBe(3); + }); + + test('should map placeholder recipients by signing order when adding items', async ({ + request, + }) => { + const envelope = await createEnvelopeWithPdf(request, 'no-recipient-placeholders.pdf'); + + await addRecipients(request, envelope.id, [ + { + email: 'second.recipient@documenso.com', + name: 'Second Recipient', + role: RecipientRole.SIGNER, + signingOrder: 2, + accessAuth: [], + actionAuth: [], + }, + { + email: 'first.recipient@documenso.com', + name: 'First Recipient', + role: RecipientRole.SIGNER, + signingOrder: 1, + accessAuth: [], + actionAuth: [], + }, + ]); + + await createEnvelopeItemsWithPdf(request, envelope.id, 'project-proposal-single-recipient.pdf'); + + const recipients = await prisma.recipient.findMany({ + where: { envelopeId: envelope.id }, + }); + + const firstRecipient = recipients.find((recipient) => recipient.signingOrder === 1); + + expect(firstRecipient).toBeDefined(); + + const fields = await prisma.field.findMany({ + where: { envelopeId: envelope.id }, + }); + + expect(fields.length).toBeGreaterThan(0); + expect(fields.every((field) => field.recipientId === firstRecipient!.id)).toBe(true); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts index 5dc4794e3..e7eb70195 100644 --- a/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts +++ b/packages/app-tests/e2e/api/v2/test-unauthorized-api-access.spec.ts @@ -4730,252 +4730,6 @@ test.describe('Document API V2', () => { }); }); - test.describe('Envelope item delete endpoint', () => { - test('should block unauthorized access to envelope item delete endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ - where: { envelopeId: doc.id }, - }); - - const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, { - headers: { Authorization: `Bearer ${tokenB}` }, - data: { - envelopeId: doc.id, - envelopeItemId: envelopeItem.id, - }, - }); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(404); - }); - - test('should allow authorized access to envelope item delete endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ - where: { envelopeId: doc.id }, - }); - - const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/delete`, { - headers: { Authorization: `Bearer ${tokenA}` }, - data: { - envelopeId: doc.id, - envelopeItemId: envelopeItem.id, - }, - }); - - expect(res.ok()).toBeTruthy(); - expect(res.status()).toBe(200); - }); - }); - - test.describe('Envelope attachment find endpoint', () => { - test('should block unauthorized access to envelope attachment find endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const res = await request.get( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, - { - headers: { Authorization: `Bearer ${tokenB}` }, - }, - ); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(404); - }); - - test('should allow authorized access to envelope attachment find endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const res = await request.get( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, - { - headers: { Authorization: `Bearer ${tokenA}` }, - }, - ); - - expect(res.ok()).toBeTruthy(); - expect(res.status()).toBe(200); - }); - }); - - test.describe('Envelope attachment create endpoint', () => { - test('should block unauthorized access to envelope attachment create endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, - { - headers: { Authorization: `Bearer ${tokenB}` }, - data: { - envelopeId: doc.id, - data: { - label: 'Test Attachment', - data: 'https://example.com/file.pdf', - }, - }, - }, - ); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(404); - }); - - test('should allow authorized access to envelope attachment create endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, - { - headers: { Authorization: `Bearer ${tokenA}` }, - data: { - envelopeId: doc.id, - data: { - label: 'Test Attachment', - data: 'https://example.com/file.pdf', - }, - }, - }, - ); - - expect(res.ok()).toBeTruthy(); - expect(res.status()).toBe(200); - }); - }); - - test.describe('Envelope attachment update endpoint', () => { - test('should block unauthorized access to envelope attachment update endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const attachment = await prisma.envelopeAttachment.create({ - data: { - envelopeId: doc.id, - type: 'link', - label: 'Original Label', - data: 'https://example.com/original.pdf', - }, - }); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, - { - headers: { Authorization: `Bearer ${tokenB}` }, - data: { - id: attachment.id, - data: { - label: 'Updated Label', - data: 'https://example.com/updated.pdf', - }, - }, - }, - ); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(404); - }); - - test('should allow authorized access to envelope attachment update endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const attachment = await prisma.envelopeAttachment.create({ - data: { - envelopeId: doc.id, - type: 'link', - label: 'Original Label', - data: 'https://example.com/original.pdf', - }, - }); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, - { - headers: { Authorization: `Bearer ${tokenA}` }, - data: { - id: attachment.id, - data: { - label: 'Updated Label', - data: 'https://example.com/updated.pdf', - }, - }, - }, - ); - - expect(res.ok()).toBeTruthy(); - expect(res.status()).toBe(200); - }); - }); - - test.describe('Envelope attachment delete endpoint', () => { - test('should block unauthorized access to envelope attachment delete endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const attachment = await prisma.envelopeAttachment.create({ - data: { - envelopeId: doc.id, - type: 'link', - label: 'Test Attachment', - data: 'https://example.com/file.pdf', - }, - }); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, - { - headers: { Authorization: `Bearer ${tokenB}` }, - data: { id: attachment.id }, - }, - ); - - expect(res.ok()).toBeFalsy(); - expect(res.status()).toBe(404); - }); - - test('should allow authorized access to envelope attachment delete endpoint', async ({ - request, - }) => { - const doc = await seedBlankDocument(userA, teamA.id); - - const attachment = await prisma.envelopeAttachment.create({ - data: { - envelopeId: doc.id, - type: 'link', - label: 'Test Attachment', - data: 'https://example.com/file.pdf', - }, - }); - - const res = await request.post( - `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, - { - headers: { Authorization: `Bearer ${tokenA}` }, - data: { id: attachment.id }, - }, - ); - - expect(res.ok()).toBeTruthy(); - expect(res.status()).toBe(200); - }); - }); - test.describe('Envelope audit logs endpoint', () => { test('should block unauthorized access to envelope audit logs endpoint', async ({ request, diff --git a/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-attachments.spec.ts b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-attachments.spec.ts new file mode 100644 index 000000000..76e0c748e --- /dev/null +++ b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-attachments.spec.ts @@ -0,0 +1,220 @@ +import { expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUser } from '@documenso/prisma/seed/users'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ + mode: 'parallel', +}); + +test.describe('Envelope Attachments API V2', () => { + let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'userA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'userB', + expiresIn: null, + })); + }); + + test.describe('Envelope attachment find endpoint', () => { + test('should block unauthorized access to envelope attachment find endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, + { + headers: { Authorization: `Bearer ${tokenB}` }, + }, + ); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment find endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.get( + `${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment?envelopeId=${doc.id}`, + { + headers: { Authorization: `Bearer ${tokenA}` }, + }, + ); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope attachment create endpoint', () => { + test('should block unauthorized access to envelope attachment create endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeId: doc.id, + data: { + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment create endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeId: doc.id, + data: { + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope attachment update endpoint', () => { + test('should block unauthorized access to envelope attachment update endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Original Label', + data: 'https://example.com/original.pdf', + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + id: attachment.id, + data: { + label: 'Updated Label', + data: 'https://example.com/updated.pdf', + }, + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment update endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Original Label', + data: 'https://example.com/original.pdf', + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/update`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + id: attachment.id, + data: { + label: 'Updated Label', + data: 'https://example.com/updated.pdf', + }, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); + + test.describe('Envelope attachment delete endpoint', () => { + test('should block unauthorized access to envelope attachment delete endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { id: attachment.id }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow authorized access to envelope attachment delete endpoint', async ({ + request, + }) => { + const doc = await seedBlankDocument(userA, teamA.id); + + const attachment = await prisma.envelopeAttachment.create({ + data: { + envelopeId: doc.id, + type: 'link', + label: 'Test Attachment', + data: 'https://example.com/file.pdf', + }, + }); + + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/attachment/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { id: attachment.id }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + }); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-bulk.spec.ts b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-bulk.spec.ts new file mode 100644 index 000000000..8540876cd --- /dev/null +++ b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-bulk.spec.ts @@ -0,0 +1,390 @@ +import { expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; +import { EnvelopeType, FolderType } from '@prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedBlankFolder } from '@documenso/prisma/seed/folders'; +import { seedUser } from '@documenso/prisma/seed/users'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ + mode: 'parallel', +}); + +// Todo: Remove skip once the API endpoints are released. +test.describe.skip('Envelope Bulk API V2', () => { + let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'userA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'userB', + expiresIn: null, + })); + }); + + test.describe('Envelope bulk move endpoint', () => { + test('should block unauthorized access to envelope bulk move endpoint', async ({ request }) => { + // Create a document owned by userA + const doc = await seedBlankDocument(userA, teamA.id); + + // UserB tries to move userA's document + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeIds: [doc.id], + envelopeType: EnvelopeType.DOCUMENT, + folderId: null, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.movedCount).toBe(0); + + // Verify in database that the document was not modified + const docInDb = await prisma.envelope.findFirst({ + where: { id: doc.id }, + }); + + expect(docInDb).not.toBeNull(); + expect(docInDb?.folderId).toBeNull(); + }); + + test('should block moving envelopes to unauthorized folder', async ({ request }) => { + // Create a document owned by userB + const doc = await seedBlankDocument(userB, teamB.id); + + // Create a folder owned by userA + const folderA = await seedBlankFolder(userA, teamA.id, { + createFolderOptions: { + name: 'UserA Folder', + type: FolderType.DOCUMENT, + }, + }); + + // UserB tries to move their document to userA's folder + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeIds: [doc.id], + envelopeType: EnvelopeType.DOCUMENT, + folderId: folderA.id, + }, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + + // Verify in database that the document was not modified + const docInDb = await prisma.envelope.findFirst({ + where: { id: doc.id }, + }); + + expect(docInDb).not.toBeNull(); + expect(docInDb?.folderId).toBeNull(); + }); + + test('should allow authorized access to envelope bulk move endpoint', async ({ request }) => { + // Create a document owned by userA + const doc = await seedBlankDocument(userA, teamA.id); + + // Create a folder owned by userA + const folderA = await seedBlankFolder(userA, teamA.id, { + createFolderOptions: { + name: 'UserA Folder', + type: FolderType.DOCUMENT, + }, + }); + + // UserA moves their own document to their own folder + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [doc.id], + envelopeType: EnvelopeType.DOCUMENT, + folderId: folderA.id, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.movedCount).toBe(1); + + // Verify in database that the document was moved to the folder + const docInDb = await prisma.envelope.findFirst({ + where: { id: doc.id }, + }); + + expect(docInDb).not.toBeNull(); + expect(docInDb?.folderId).toBe(folderA.id); + }); + + test('should only move authorized envelopes when given mixed array of envelope IDs', async ({ + request, + }) => { + // Create documents owned by userA + const docA1 = await seedBlankDocument(userA, teamA.id); + const docA2 = await seedBlankDocument(userA, teamA.id); + + // Create a document owned by userB + const docB = await seedBlankDocument(userB, teamB.id); + + // Create a folder owned by userA + const folderA = await seedBlankFolder(userA, teamA.id, { + createFolderOptions: { + name: 'UserA Folder', + type: FolderType.DOCUMENT, + }, + }); + + // UserA tries to move a mix of their own documents and userB's document + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [docA1.id, docB.id, docA2.id], + envelopeType: EnvelopeType.DOCUMENT, + folderId: folderA.id, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + // Only userA's documents should be moved + expect(body.movedCount).toBe(2); + + // Verify userA's documents were moved + const docA1InDb = await prisma.envelope.findFirst({ + where: { id: docA1.id }, + }); + expect(docA1InDb).not.toBeNull(); + expect(docA1InDb?.folderId).toBe(folderA.id); + + const docA2InDb = await prisma.envelope.findFirst({ + where: { id: docA2.id }, + }); + expect(docA2InDb).not.toBeNull(); + expect(docA2InDb?.folderId).toBe(folderA.id); + + // Verify userB's document was NOT moved + const docBInDb = await prisma.envelope.findFirst({ + where: { id: docB.id }, + }); + expect(docBInDb).not.toBeNull(); + expect(docBInDb?.folderId).toBeNull(); + }); + + test('should move zero envelopes when all envelope IDs in array are unauthorized', async ({ + request, + }) => { + // Create documents owned by userB + const docB1 = await seedBlankDocument(userB, teamB.id); + const docB2 = await seedBlankDocument(userB, teamB.id); + + // Create a folder owned by userA + const folderA = await seedBlankFolder(userA, teamA.id, { + createFolderOptions: { + name: 'UserA Folder', + type: FolderType.DOCUMENT, + }, + }); + + // UserA tries to move userB's documents + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/move`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [docB1.id, docB2.id], + envelopeType: EnvelopeType.DOCUMENT, + folderId: folderA.id, + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.movedCount).toBe(0); + + // Verify userB's documents were NOT moved + const docB1InDb = await prisma.envelope.findFirst({ + where: { id: docB1.id }, + }); + expect(docB1InDb).not.toBeNull(); + expect(docB1InDb?.folderId).toBeNull(); + + const docB2InDb = await prisma.envelope.findFirst({ + where: { id: docB2.id }, + }); + expect(docB2InDb).not.toBeNull(); + expect(docB2InDb?.folderId).toBeNull(); + }); + }); + + test.describe('Envelope bulk delete endpoint', () => { + test('should block unauthorized access to envelope bulk delete endpoint', async ({ + request, + }) => { + // Create a document owned by userA + const doc = await seedBlankDocument(userA, teamA.id); + + // UserB tries to delete userA's document + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, { + headers: { Authorization: `Bearer ${tokenB}` }, + data: { + envelopeIds: [doc.id], + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.deletedCount).toBe(0); + // Unauthorized envelope ID should be in failedIds + expect(body.failedIds).toEqual([doc.id]); + + // Verify in database that the document still exists + const docInDb = await prisma.envelope.findFirst({ + where: { id: doc.id }, + }); + + expect(docInDb).not.toBeNull(); + expect(docInDb?.id).toBe(doc.id); + expect(docInDb?.deletedAt).toBeNull(); + }); + + test('should allow authorized access to envelope bulk delete endpoint', async ({ request }) => { + // Create a document owned by userA + const doc = await seedBlankDocument(userA, teamA.id); + + // UserA deletes their own document + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [doc.id], + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.deletedCount).toBe(1); + expect(body.failedIds).toEqual([]); + + // Verify in database that the document no longer exists + const docInDb = await prisma.envelope.findFirst({ + where: { id: doc.id }, + }); + + expect(docInDb).toBeNull(); + }); + + test('should only delete authorized envelopes when given mixed array of envelope IDs', async ({ + request, + }) => { + // Create documents owned by userA + const docA1 = await seedBlankDocument(userA, teamA.id); + const docA2 = await seedBlankDocument(userA, teamA.id); + + // Create a document owned by userB + const docB = await seedBlankDocument(userB, teamB.id); + + // UserA tries to delete a mix of their own documents and userB's document + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [docA1.id, docB.id, docA2.id], + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + // Only userA's documents should be deleted + expect(body.deletedCount).toBe(2); + // Unauthorized envelope ID (docB) should be in failedIds + expect(body.failedIds).toEqual([docB.id]); + + // Verify userA's documents were deleted + const docA1InDb = await prisma.envelope.findFirst({ + where: { id: docA1.id }, + }); + expect(docA1InDb).toBeNull(); + + const docA2InDb = await prisma.envelope.findFirst({ + where: { id: docA2.id }, + }); + expect(docA2InDb).toBeNull(); + + // Verify userB's document was NOT deleted + const docBInDb = await prisma.envelope.findFirst({ + where: { id: docB.id }, + }); + expect(docBInDb).not.toBeNull(); + expect(docBInDb?.id).toBe(docB.id); + expect(docBInDb?.deletedAt).toBeNull(); + }); + + test('should delete zero envelopes when all envelope IDs in array are unauthorized', async ({ + request, + }) => { + // Create documents owned by userB + const docB1 = await seedBlankDocument(userB, teamB.id); + const docB2 = await seedBlankDocument(userB, teamB.id); + + // UserA tries to delete userB's documents + const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/bulk/delete`, { + headers: { Authorization: `Bearer ${tokenA}` }, + data: { + envelopeIds: [docB1.id, docB2.id], + }, + }); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.deletedCount).toBe(0); + // All unauthorized envelope IDs should be in failedIds + expect(body.failedIds).toEqual(expect.arrayContaining([docB1.id, docB2.id])); + expect(body.failedIds).toHaveLength(2); + + // Verify userB's documents were NOT deleted + const docB1InDb = await prisma.envelope.findFirst({ + where: { id: docB1.id }, + }); + expect(docB1InDb).not.toBeNull(); + expect(docB1InDb?.id).toBe(docB1.id); + expect(docB1InDb?.deletedAt).toBeNull(); + + const docB2InDb = await prisma.envelope.findFirst({ + where: { id: docB2.id }, + }); + expect(docB2InDb).not.toBeNull(); + expect(docB2InDb?.id).toBe(docB2.id); + expect(docB2InDb?.deletedAt).toBeNull(); + }); + }); +}); diff --git a/packages/app-tests/e2e/auto-placing-fields/auto-place-fields-document.spec.ts b/packages/app-tests/e2e/auto-placing-fields/auto-place-fields-document.spec.ts new file mode 100644 index 000000000..1b9fa4ed9 --- /dev/null +++ b/packages/app-tests/e2e/auto-placing-fields/auto-place-fields-document.spec.ts @@ -0,0 +1,307 @@ +import { type Page, expect, test } from '@playwright/test'; +import path from 'path'; + +import { prisma } from '@documenso/prisma'; +import { RecipientRole } from '@documenso/prisma/client'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +const FIXTURES_DIR = path.join(__dirname, '../../../assets/fixtures/auto-placement'); + +const SINGLE_PLACEHOLDER_PDF_PATH = path.join( + FIXTURES_DIR, + 'project-proposal-single-recipient.pdf', +); + +const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join( + FIXTURES_DIR, + 'project-proposal-multiple-fields-and-recipients.pdf', +); + +const NO_RECIPIENT_PDF_PATH = path.join(FIXTURES_DIR, 'no-recipient-placeholders.pdf'); + +const INVALID_FIELD_TYPE_PDF_PATH = path.join(FIXTURES_DIR, 'invalid-field-type.pdf'); + +const FIELD_TYPE_ONLY_PDF_PATH = path.join(FIXTURES_DIR, 'field-type-only.pdf'); + +const setTeamDefaultRecipients = async ( + teamId: number, + defaultRecipients: Array<{ email: string; name: string; role: RecipientRole }>, +) => { + const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({ + where: { + team: { + id: teamId, + }, + }, + }); + + await prisma.teamGlobalSettings.update({ + where: { + id: teamSettings.id, + }, + data: { + defaultRecipients, + }, + }); +}; + +const setupUserAndSignIn = async (page: Page) => { + const { user, team } = await seedUser(); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + return { user, team }; +}; + +const uploadPdf = async (page: Page, team: { url: string }, pdfPath: string) => { + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page + .locator('input[type=file]') + .nth(1) + .evaluate((e) => { + if (e instanceof HTMLInputElement) { + e.click(); + } + }), + ]); + + await fileChooser.setFiles(pdfPath); + + // Wait for redirect to v2 envelope editor. + await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`)); + + // Extract envelope ID from URL. + const urlParts = page.url().split('/'); + const envelopeId = urlParts.find((part) => part.startsWith('envelope_')); + + if (!envelopeId) { + throw new Error('Could not extract envelope ID from URL'); + } + + return envelopeId; +}; + +test.describe('PDF Placeholders with single recipient', () => { + test('[AUTO_PLACING_FIELDS]: should create placeholder recipients even with default recipients', async ({ + page, + }) => { + const { user, team } = await seedUser(); + + await setTeamDefaultRecipients(team.id, [ + { + email: user.email, + name: user.name || user.email, + role: RecipientRole.CC, + }, + ]); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH); + + await expect(async () => { + const recipients = await prisma.recipient.findMany({ + where: { envelopeId }, + }); + + const placeholderRecipient = recipients.find( + (recipient) => recipient.email === 'recipient.1@documenso.com', + ); + + const defaultRecipient = recipients.find((recipient) => recipient.email === user.email); + + expect(placeholderRecipient).toBeDefined(); + expect(defaultRecipient).toBeDefined(); + + const fields = await prisma.field.findMany({ + where: { envelopeId }, + }); + + expect(fields.length).toBeGreaterThan(0); + expect(fields.every((field) => field.recipientId === placeholderRecipient!.id)).toBe(true); + }).toPass(); + }); + + test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({ + page, + }) => { + const { team } = await setupUserAndSignIn(page); + await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH); + + // V2 editor shows recipients on the upload page under "Recipients" heading. + await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible(); + await expect(page.getByTestId('signer-email-input').first()).toHaveValue( + 'recipient.1@documenso.com', + ); + await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1'); + }); + + test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({ + page, + }) => { + const { team } = await setupUserAndSignIn(page); + const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH); + + // V2 editor renders fields on a Konva canvas, so we verify via the database. + await expect(async () => { + const fields = await prisma.field.findMany({ + where: { envelopeId }, + }); + + const fieldTypes = fields.map((f) => f.type).sort(); + expect(fieldTypes).toEqual(['EMAIL', 'NAME', 'SIGNATURE', 'TEXT'].sort()); + }).toPass(); + }); + + test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({ + page, + }) => { + const { team } = await setupUserAndSignIn(page); + const envelopeId = await uploadPdf(page, team, SINGLE_PLACEHOLDER_PDF_PATH); + + // Verify field metadata was correctly parsed from the placeholder. + await expect(async () => { + const textField = await prisma.field.findFirst({ + where: { envelopeId, type: 'TEXT' }, + }); + + expect(textField).toBeDefined(); + expect(textField!.fieldMeta).toBeDefined(); + + const meta = textField!.fieldMeta as Record; + expect(meta.required).toBe(true); + expect(meta.textAlign).toBe('right'); + }).toPass(); + }); +}); + +test.describe('PDF Placeholders with multiple recipients', () => { + test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({ + page, + }) => { + const { team } = await setupUserAndSignIn(page); + const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH); + + // V2 editor shows recipients on the upload page. + await expect(page.getByRole('heading', { name: 'Recipients' })).toBeVisible(); + + await expect(page.getByTestId('signer-email-input').first()).toHaveValue( + 'recipient.1@documenso.com', + ); + + await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue( + 'recipient.2@documenso.com', + ); + + await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue( + 'recipient.3@documenso.com', + ); + + // Verify recipients via the database for name validation since the v2 editor + // only shows the "Name" label on the first recipient row. + await expect(async () => { + const recipients = await prisma.recipient.findMany({ + where: { envelopeId }, + orderBy: { signingOrder: 'asc' }, + }); + + expect(recipients).toHaveLength(3); + expect(recipients[0].name).toBe('Recipient 1'); + expect(recipients[1].name).toBe('Recipient 2'); + expect(recipients[2].name).toBe('Recipient 3'); + }).toPass(); + }); + + test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({ + page, + }) => { + const { team } = await setupUserAndSignIn(page); + const envelopeId = await uploadPdf(page, team, MULTIPLE_PLACEHOLDER_PDF_PATH); + + // V2 editor renders fields on a Konva canvas, so we verify via the database. + await expect(async () => { + const fields = await prisma.field.findMany({ + where: { envelopeId }, + }); + + const fieldTypes = fields.map((f) => f.type).sort(); + expect(fieldTypes).toEqual( + ['SIGNATURE', 'SIGNATURE', 'SIGNATURE', 'EMAIL', 'EMAIL', 'NAME', 'TEXT', 'NUMBER'].sort(), + ); + }).toPass(); + }); +}); + +test.describe('PDF Placeholders without recipient identifier', () => { + test('[AUTO_PLACING_FIELDS]: should skip placeholders without a recipient identifier', async ({ + page, + }) => { + const { team } = await setupUserAndSignIn(page); + const envelopeId = await uploadPdf(page, team, NO_RECIPIENT_PDF_PATH); + + // Placeholders like {{signature}}, {{name}}, {{email}} have no recipient + // identifier and should be skipped entirely. No fields or auto-created + // recipients should exist. + await expect(async () => { + const fields = await prisma.field.findMany({ + where: { envelopeId }, + }); + + expect(fields).toHaveLength(0); + }).toPass(); + }); + + test('[AUTO_PLACING_FIELDS]: should skip a bare field type placeholder', async ({ page }) => { + const { team } = await setupUserAndSignIn(page); + const envelopeId = await uploadPdf(page, team, FIELD_TYPE_ONLY_PDF_PATH); + + await expect(async () => { + const fields = await prisma.field.findMany({ + where: { envelopeId }, + }); + + expect(fields).toHaveLength(0); + }).toPass(); + }); +}); + +test.describe('PDF Placeholders with invalid field types', () => { + test('[AUTO_PLACING_FIELDS]: should skip invalid field types and process valid ones', async ({ + page, + }) => { + const { team } = await setupUserAndSignIn(page); + const envelopeId = await uploadPdf(page, team, INVALID_FIELD_TYPE_PDF_PATH); + + // Only the valid placeholders (signature,r1 and email,r2) should create fields. + // The invalid ones (bogus,r1 and foobar,r2) should be skipped. + await expect(async () => { + const fields = await prisma.field.findMany({ + where: { envelopeId }, + }); + + const fieldTypes = fields.map((f) => f.type).sort(); + expect(fieldTypes).toEqual(['EMAIL', 'SIGNATURE'].sort()); + }).toPass(); + + // Both valid recipients should still be created. + await expect(async () => { + const recipients = await prisma.recipient.findMany({ + where: { envelopeId }, + orderBy: { signingOrder: 'asc' }, + }); + + expect(recipients).toHaveLength(2); + }).toPass(); + }); +}); diff --git a/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts b/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts new file mode 100644 index 000000000..3c6ba0bc2 --- /dev/null +++ b/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts @@ -0,0 +1,253 @@ +import { expect, test } from '@playwright/test'; + +import { seedDraftDocument } from '@documenso/prisma/seed/documents'; +import { seedBlankFolder } from '@documenso/prisma/seed/folders'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; +import { expectToastTextToBeVisible } from '../fixtures/generic'; + +test.describe.configure({ mode: 'parallel' }); + +const seedBulkActionsTestRequirements = async () => { + const sender = await seedUser({ setTeamEmailAsOwner: true }); + + const [doc1, doc2, doc3] = await Promise.all([ + seedDraftDocument(sender.user, sender.team.id, [], { + createDocumentOptions: { title: 'Bulk Test Doc 1' }, + }), + seedDraftDocument(sender.user, sender.team.id, [], { + createDocumentOptions: { title: 'Bulk Test Doc 2' }, + }), + seedDraftDocument(sender.user, sender.team.id, [], { + createDocumentOptions: { title: 'Bulk Test Doc 3' }, + }), + ]); + + const folder = await seedBlankFolder(sender.user, sender.team.id, { + createFolderOptions: { + name: 'Target Folder', + teamId: sender.team.id, + }, + }); + + return { + sender, + documents: [doc1, doc2, doc3], + folder, + }; +}; + +test('[BULK_ACTIONS]: can select multiple documents with checkboxes', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click(); + await expect(page.getByText('2 selected')).toBeVisible(); +}); + +test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ page }) => { + const { sender, documents } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('thead').getByRole('checkbox').click(); + + await expect(page.getByText(`${documents.length} selected`)).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('thead').getByRole('checkbox').click(); + await expect(page.getByText(/\d+ selected/)).toBeVisible(); + + await page.getByLabel('Clear selection').click(); + + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click(); + await page.getByRole('button', { name: 'Move to Folder' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByText('Move Documents to Folder')).toBeVisible(); + + await page.getByRole('button', { name: folder.name }).click(); + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + + await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`); + await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can delete multiple draft documents', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await page.locator('tr', { hasText: 'Bulk Test Doc 2' }).getByRole('checkbox').click(); + + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByText('Delete Documents')).toBeVisible(); + await expect(page.getByText('You are about to delete 2 documents')).toBeVisible(); + await expect(page.getByText('irreversible')).toBeVisible(); + + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + + await expectToastTextToBeVisible(page, 'Documents deleted'); + + await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Bulk Test Doc 2' })).not.toBeVisible(); + + await expect(page.getByRole('link', { name: 'Bulk Test Doc 3' })).toBeVisible(); +}); + +test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: folder.name }).click(); + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + + await expectToastTextToBeVisible(page, 'Documents deleted'); + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + const otherFolder = await seedBlankFolder(sender.user, sender.team.id, { + createFolderOptions: { + name: 'Other Folder', + teamId: sender.team.id, + }, + }); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await expect(page.getByRole('button', { name: folder.name })).toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('Target'); + await expect(page.getByRole('button', { name: folder.name })).toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('Other'); + await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('NonExistent'); + await expect(page.getByText('No folders found')).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ page }) => { + const { sender, documents, folder } = await seedBulkActionsTestRequirements(); + + const { prisma } = await import('@documenso/prisma'); + + await prisma.envelope.updateMany({ + where: { id: documents[0].id }, + data: { folderId: folder.id }, + }); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/documents/f/${folder.id}`, + }); + + await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible(); + + await page.locator('tr', { hasText: 'Bulk Test Doc 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByRole('button', { name: 'Home (No Folder)' }).click(); + + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + + await page.goto(`/t/${sender.team.url}/documents`); + await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible(); + + await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`); + await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible(); +}); diff --git a/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts b/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts new file mode 100644 index 000000000..a4ea41ffa --- /dev/null +++ b/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts @@ -0,0 +1,326 @@ +import { expect, test } from '@playwright/test'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { TCachedLicense, TLicenseClaim } from '@documenso/lib/types/license'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +const LICENSE_FILE_NAME = '.documenso-license.json'; +const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json'; + +/** + * Get the path to the license file. + * + * The server reads from process.cwd() which is apps/remix when the dev server runs. + * Tests run from packages/app-tests, so we need to go up to the root then into apps/remix. + */ +const getLicenseFilePath = () => { + // From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json + return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME); +}; + +/** + * Get the path to the backup license file. + */ +const getBackupLicenseFilePath = () => { + return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME); +}; + +/** + * Backup the existing license file if it exists. + */ +const backupLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(licensePath); + await fs.rename(licensePath, backupPath); + } catch (e) { + // File doesn't exist, nothing to backup + console.log(e); + } +}; + +/** + * Restore the backup license file if it exists. + */ +const restoreLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(backupPath); + await fs.rename(backupPath, licensePath); + } catch (e) { + // Backup doesn't exist, nothing to restore + console.log(e); + } +}; + +/** + * Write a license file with the given data. + * Pass null to delete the license file. + */ +const writeLicenseFile = async (data: TCachedLicense | null) => { + const licensePath = getLicenseFilePath(); + + if (data === null) { + await fs.unlink(licensePath).catch(() => { + // File doesn't exist, ignore + }); + } else { + await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8'); + } +}; + +/** + * Create a mock license object with the given flags. + */ +const createMockLicenseWithFlags = (flags: TLicenseClaim): TCachedLicense => { + return { + lastChecked: new Date().toISOString(), + license: { + status: 'ACTIVE', + createdAt: new Date(), + name: 'Test License', + periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + cancelAtPeriodEnd: false, + licenseKey: 'test-license-key', + flags, + }, + requestedLicenseKey: 'test-license-key', + derivedStatus: 'ACTIVE', + unauthorizedFlagUsage: false, + }; +}; + +// Run tests serially to avoid race conditions with the license file +test.describe.configure({ mode: 'serial' }); + +// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE. +test.describe.skip('Enterprise Feature Restrictions', () => { + test.beforeAll(async () => { + // Backup any existing license file before running tests + await backupLicenseFile(); + }); + + test.afterAll(async () => { + // Restore the backup license file after all tests complete + await restoreLicenseFile(); + }); + + test.beforeEach(async () => { + // Clean up license file before each test to ensure clean state + await writeLicenseFile(null); + }); + + test.afterEach(async () => { + // Clean up license file after each test + await writeLicenseFile(null); + }); + + test('[ADMIN CLAIMS]: shows restricted features with asterisk when no license', async ({ + page, + }) => { + // Ensure no license file exists + await writeLicenseFile(null); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Check that enterprise features have asterisks (are restricted) + // These are the enterprise features that should be marked with * + await expect(page.getByText(/Email domains\s¹/)).toBeVisible(); + await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/21 CFR\s¹/)).toBeVisible(); + await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible(); + + // Check that the alert is visible + await expect( + page.getByText('Your current license does not include these features.'), + ).toBeVisible(); + await expect(page.getByRole('link', { name: 'Learn more' })).toBeVisible(); + + // Check that enterprise feature checkboxes are disabled + const emailDomainsCheckbox = page.locator('#flag-emailDomains'); + await expect(emailDomainsCheckbox).toBeDisabled(); + + const cfr21Checkbox = page.locator('#flag-cfr21'); + await expect(cfr21Checkbox).toBeDisabled(); + + const authPortalCheckbox = page.locator('#flag-authenticationPortal'); + await expect(authPortalCheckbox).toBeDisabled(); + }); + + test('[ADMIN CLAIMS]: no restrictions when license has all enterprise features', async ({ + page, + }) => { + // Create a license with ALL enterprise features enabled + await writeLicenseFile( + createMockLicenseWithFlags({ + emailDomains: true, + embedAuthoring: true, + embedAuthoringWhiteLabel: true, + cfr21: true, + authenticationPortal: true, + billing: true, + }), + ); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Check that enterprise features do NOT have asterisks + // They should show without the * since the license covers them + await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Embed authoring\s¹/)).not.toBeVisible(); + await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Authentication portal\s¹/)).not.toBeVisible(); + + // The plain labels should be visible (without asterisks) + await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains'); + await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR'); + + // The alert should NOT be visible + await expect( + page.getByText('Your current license does not include these features.'), + ).not.toBeVisible(); + + // Check that enterprise feature checkboxes are enabled + const emailDomainsCheckbox = page.locator('#flag-emailDomains'); + await expect(emailDomainsCheckbox).toBeEnabled(); + + const cfr21Checkbox = page.locator('#flag-cfr21'); + await expect(cfr21Checkbox).toBeEnabled(); + + const authPortalCheckbox = page.locator('#flag-authenticationPortal'); + await expect(authPortalCheckbox).toBeEnabled(); + }); + + test('[ADMIN CLAIMS]: only unlicensed features show asterisk with partial license', async ({ + page, + }) => { + // Create a license with SOME enterprise features (emailDomains and cfr21) + await writeLicenseFile( + createMockLicenseWithFlags({ + emailDomains: true, + cfr21: true, + // embedAuthoring, embedAuthoringWhiteLabel, authenticationPortal are NOT included + }), + ); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Features NOT in license should have asterisks + await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible(); + + // Features IN license should NOT have asterisks + await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible(); + await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible(); + + // The plain labels for licensed features should be visible + await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains'); + await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR'); + + // Alert should be visible since some features are restricted + await expect( + page.getByText('Your current license does not include these features.'), + ).toBeVisible(); + + // Licensed features should be enabled + const emailDomainsCheckbox = page.locator('#flag-emailDomains'); + await expect(emailDomainsCheckbox).toBeEnabled(); + + const cfr21Checkbox = page.locator('#flag-cfr21'); + await expect(cfr21Checkbox).toBeEnabled(); + + // Unlicensed features should be disabled + const embedAuthoringCheckbox = page.locator('#flag-embedAuthoring'); + await expect(embedAuthoringCheckbox).toBeDisabled(); + + const authPortalCheckbox = page.locator('#flag-authenticationPortal'); + await expect(authPortalCheckbox).toBeDisabled(); + }); + + test('[ADMIN CLAIMS]: non-enterprise features are always enabled', async ({ page }) => { + // Ensure no license file exists + await writeLicenseFile(null); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Non-enterprise features should NOT have asterisks + await expect(page.getByText(/Unlimited documents\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Branding\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Embed signing\s¹/)).not.toBeVisible(); + + // Non-enterprise features should always be enabled + const unlimitedDocsCheckbox = page.locator('#flag-unlimitedDocuments'); + await expect(unlimitedDocsCheckbox).toBeEnabled(); + + const brandingCheckbox = page.locator('#flag-allowCustomBranding'); + await expect(brandingCheckbox).toBeEnabled(); + + const embedSigningCheckbox = page.locator('#flag-embedSigning'); + await expect(embedSigningCheckbox).toBeEnabled(); + }); +}); diff --git a/packages/app-tests/e2e/license/license-status-banner.spec.ts b/packages/app-tests/e2e/license/license-status-banner.spec.ts new file mode 100644 index 000000000..7c40a6b90 --- /dev/null +++ b/packages/app-tests/e2e/license/license-status-banner.spec.ts @@ -0,0 +1,392 @@ +import { expect, test } from '@playwright/test'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { TCachedLicense } from '@documenso/lib/types/license'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +const LICENSE_FILE_NAME = '.documenso-license.json'; +const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json'; + +/** + * Get the path to the license file. + * + * The server reads from process.cwd() which is apps/remix when the dev server runs. + * Tests run from packages/app-tests, so we need to go up to the root then into apps/remix. + */ +const getLicenseFilePath = () => { + // From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json + return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME); +}; + +/** + * Get the path to the backup license file. + */ +const getBackupLicenseFilePath = () => { + return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME); +}; + +/** + * Backup the existing license file if it exists. + */ +const backupLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(licensePath); + await fs.rename(licensePath, backupPath); + } catch (e) { + // File doesn't exist, nothing to backup + console.log(e); + } +}; + +/** + * Restore the backup license file if it exists. + */ +const restoreLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(backupPath); + await fs.rename(backupPath, licensePath); + } catch (e) { + // Backup doesn't exist, nothing to restore + console.log(e); + } +}; + +/** + * Write a license file with the given data. + * Pass null to delete the license file. + */ +const writeLicenseFile = async (data: TCachedLicense | null) => { + const licensePath = getLicenseFilePath(); + + if (data === null) { + await fs.unlink(licensePath).catch(() => { + // File doesn't exist, ignore + }); + } else { + await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8'); + } +}; + +/** + * Create a mock license object with the given status and unauthorized flag. + */ +const createMockLicense = ( + status: 'ACTIVE' | 'EXPIRED' | 'PAST_DUE', + unauthorizedFlagUsage: boolean, +): TCachedLicense => { + return { + lastChecked: new Date().toISOString(), + license: { + status, + createdAt: new Date(), + name: 'Test License', + periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + cancelAtPeriodEnd: false, + licenseKey: 'test-license-key', + flags: {}, + }, + requestedLicenseKey: 'test-license-key', + derivedStatus: unauthorizedFlagUsage ? 'UNAUTHORIZED' : status, + unauthorizedFlagUsage, + }; +}; + +/** + * Create a mock license object with no license data (only unauthorized flag). + */ +const createMockUnauthorizedWithoutLicense = (): TCachedLicense => { + return { + lastChecked: new Date().toISOString(), + license: null, + unauthorizedFlagUsage: true, + derivedStatus: 'UNAUTHORIZED', + }; +}; + +// Run tests serially to avoid race conditions with the license file +test.describe.configure({ mode: 'serial' }); + +// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE. +test.describe.skip('License Status Banner', () => { + test.beforeAll(async () => { + // Backup any existing license file before running tests + await backupLicenseFile(); + }); + + test.afterAll(async () => { + // Restore the backup license file after all tests complete + await restoreLicenseFile(); + }); + + test.beforeEach(async () => { + // Clean up license file before each test to ensure clean state + await writeLicenseFile(null); + }); + + test.afterEach(async () => { + // Clean up license file after each test + await writeLicenseFile(null); + }); + + test('[ADMIN]: no banner when license file is missing', async ({ page }) => { + // Ensure no license file exists BEFORE any page loads + await writeLicenseFile(null); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should not be visible (no license file) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner messages should not be visible (no license file means no banner) + await expect(page.getByText('License payment overdue')).not.toBeVisible(); + await expect(page.getByText('License expired')).not.toBeVisible(); + await expect(page.getByText('Invalid License Type')).not.toBeVisible(); + await expect(page.getByText('Missing License')).not.toBeVisible(); + }); + + test('[ADMIN]: no banner when license is ACTIVE', async ({ page }) => { + // Create an ACTIVE license BEFORE any page loads + await writeLicenseFile(createMockLicense('ACTIVE', false)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should not be visible (license is ACTIVE) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner messages should not be visible (license is ACTIVE) + await expect(page.getByText('License payment overdue')).not.toBeVisible(); + await expect(page.getByText('License expired')).not.toBeVisible(); + await expect(page.getByText('Invalid License Type')).not.toBeVisible(); + }); + + test('[ADMIN]: admin banner shows PAST_DUE warning', async ({ page }) => { + // Create a PAST_DUE license BEFORE any page loads + await writeLicenseFile(createMockLicense('PAST_DUE', false)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (only shows for EXPIRED + unauthorized) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show PAST_DUE message + await expect(page.getByText('License payment overdue')).toBeVisible(); + await expect( + page.getByText('Please update your payment to avoid service disruptions.'), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test('[ADMIN]: admin banner shows EXPIRED error', async ({ page }) => { + // Create an EXPIRED license WITHOUT unauthorized usage BEFORE any page loads + await writeLicenseFile(createMockLicense('EXPIRED', false)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (requires BOTH expired AND unauthorized) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show EXPIRED message + await expect(page.getByText('License expired')).toBeVisible(); + await expect( + page.getByText('Please renew your license to continue using enterprise features.'), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test.skip('[ADMIN]: global banner shows when EXPIRED with unauthorized usage', async ({ + page, + }) => { + // Create an EXPIRED license WITH unauthorized usage BEFORE any page loads + await writeLicenseFile(createMockLicense('EXPIRED', true)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner SHOULD be visible (EXPIRED + unauthorized) + await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible(); + + // Admin banner should show UNAUTHORIZED message (takes precedence over EXPIRED) + await expect(page.getByText('Invalid License Type')).toBeVisible(); + await expect( + page.getByText( + 'Your Documenso instance is using features that are not part of your license.', + ), + ).toBeVisible(); + }); + + test('[ADMIN]: admin banner shows UNAUTHORIZED when flags are misused with license', async ({ + page, + }) => { + // Create an ACTIVE license but WITH unauthorized flag usage BEFORE any page loads + await writeLicenseFile(createMockLicense('ACTIVE', true)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (requires EXPIRED status) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show UNAUTHORIZED message + await expect(page.getByText('Invalid License Type')).toBeVisible(); + await expect( + page.getByText( + 'Your Documenso instance is using features that are not part of your license.', + ), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test('[ADMIN]: admin banner shows Invalid License Type when unauthorized without license data', async ({ + page, + }) => { + // Create a license file with unauthorized flag but no license data BEFORE any page loads + // Note: Even without license data, the banner shows "Invalid License Type" because the + // license file exists (just with license: null). The "Missing License" message would only + // show if the entire license prop was null, which doesn't happen with a valid file. + await writeLicenseFile(createMockUnauthorizedWithoutLicense()); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (no EXPIRED status, only unauthorized flag) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show Invalid License Type message (unauthorized flag is set) + await expect(page.getByText('Invalid License Type')).toBeVisible(); + await expect( + page.getByText( + 'Your Documenso instance is using features that are not part of your license.', + ), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test.skip('[ADMIN]: global banner visible on non-admin pages when EXPIRED with unauthorized', async ({ + page, + }) => { + // Create an EXPIRED license WITH unauthorized usage BEFORE any page loads + await writeLicenseFile(createMockLicense('EXPIRED', true)); + + const { user } = await seedUser(); + + // Navigate to documents page - license is read during page load + await apiSignin({ + page, + email: user.email, + redirectPath: '/documents', + }); + + // Global banner SHOULD be visible on any authenticated page (EXPIRED + unauthorized) + await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible(); + }); +}); diff --git a/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts b/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts new file mode 100644 index 000000000..097717d0b --- /dev/null +++ b/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts @@ -0,0 +1,256 @@ +import { expect, test } from '@playwright/test'; + +import { FolderType } from '@documenso/prisma/client'; +import { seedBlankFolder } from '@documenso/prisma/seed/folders'; +import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; +import { expectToastTextToBeVisible } from '../fixtures/generic'; + +test.describe.configure({ mode: 'parallel' }); + +const seedBulkActionsTestRequirements = async () => { + const sender = await seedUser({ setTeamEmailAsOwner: true }); + + const [template1, template2, template3] = await Promise.all([ + seedBlankTemplate(sender.user, sender.team.id, { + createTemplateOptions: { title: 'Bulk Test Template 1' }, + }), + seedBlankTemplate(sender.user, sender.team.id, { + createTemplateOptions: { title: 'Bulk Test Template 2' }, + }), + seedBlankTemplate(sender.user, sender.team.id, { + createTemplateOptions: { title: 'Bulk Test Template 3' }, + }), + ]); + + const folder = await seedBlankFolder(sender.user, sender.team.id, { + createFolderOptions: { + name: 'Target Template Folder', + teamId: sender.team.id, + type: FolderType.TEMPLATE, + }, + }); + + return { + sender, + templates: [template1, template2, template3], + folder, + }; +}; + +test('[BULK_ACTIONS]: can select multiple templates with checkboxes', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click(); + await expect(page.getByText('2 selected')).toBeVisible(); +}); + +test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ page }) => { + const { sender, templates } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('thead').getByRole('checkbox').click(); + + await expect(page.getByText(`${templates.length} selected`)).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('thead').getByRole('checkbox').click(); + await expect(page.getByText(/\d+ selected/)).toBeVisible(); + + await page.getByLabel('Clear selection').click(); + + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click(); + await page.getByRole('button', { name: 'Move to Folder' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByText('Move Templates to Folder')).toBeVisible(); + + await page.getByRole('button', { name: folder.name }).click(); + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + + await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`); + await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can delete multiple templates', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await page.locator('tr', { hasText: 'Bulk Test Template 2' }).getByRole('checkbox').click(); + + await page.getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByText('Delete Templates')).toBeVisible(); + await expect(page.getByText('You are about to delete 2 templates')).toBeVisible(); + await expect(page.getByText('irreversible')).toBeVisible(); + + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + + await expectToastTextToBeVisible(page, 'Templates deleted'); + + await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Bulk Test Template 2' })).not.toBeVisible(); + + await expect(page.getByRole('link', { name: 'Bulk Test Template 3' })).toBeVisible(); +}); + +test('[BULK_ACTIONS]: selection clears after successful move', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await page.getByRole('button', { name: folder.name }).click(); + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: selection clears after successful delete', async ({ page }) => { + const { sender } = await seedBulkActionsTestRequirements(); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + + await expectToastTextToBeVisible(page, 'Templates deleted'); + await expect(page.getByText(/\d+ selected/)).not.toBeVisible(); +}); + +test('[BULK_ACTIONS]: can search for folders in move dialog', async ({ page }) => { + const { sender, folder } = await seedBulkActionsTestRequirements(); + + const otherFolder = await seedBlankFolder(sender.user, sender.team.id, { + createFolderOptions: { + name: 'Other Template Folder', + teamId: sender.team.id, + type: FolderType.TEMPLATE, + }, + }); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates`, + }); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await expect(page.getByRole('button', { name: folder.name })).toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('Target'); + await expect(page.getByRole('button', { name: folder.name })).toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).not.toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('Other'); + await expect(page.getByRole('button', { name: folder.name })).not.toBeVisible(); + await expect(page.getByRole('button', { name: otherFolder.name })).toBeVisible(); + + await page.getByPlaceholder('Search folders...').fill('NonExistent'); + await expect(page.getByText('No folders found')).toBeVisible(); +}); + +test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ page }) => { + const { sender, templates, folder } = await seedBulkActionsTestRequirements(); + + const { prisma } = await import('@documenso/prisma'); + + await prisma.envelope.updateMany({ + where: { id: templates[0].id }, + data: { folderId: folder.id }, + }); + + await apiSignin({ + page, + email: sender.user.email, + redirectPath: `/t/${sender.team.url}/templates/f/${folder.id}`, + }); + + await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible(); + + await page.locator('tr', { hasText: 'Bulk Test Template 1' }).getByRole('checkbox').click(); + await expect(page.getByText('1 selected')).toBeVisible(); + + await page.getByRole('button', { name: 'Move to Folder' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + await page.getByRole('button', { name: 'Home (No Folder)' }).click(); + + await page.getByRole('button', { name: 'Move' }).click(); + + await expectToastTextToBeVisible(page, 'Selected items have been moved.'); + + await page.goto(`/t/${sender.team.url}/templates`); + await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible(); + + await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`); + await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).not.toBeVisible(); +}); diff --git a/packages/app-tests/e2e/user/auth-flow.spec.ts b/packages/app-tests/e2e/user/auth-flow.spec.ts index f56d09a8f..8eda007b3 100644 --- a/packages/app-tests/e2e/user/auth-flow.spec.ts +++ b/packages/app-tests/e2e/user/auth-flow.spec.ts @@ -23,7 +23,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page await signSignaturePad(page); - await page.getByRole('button', { name: 'Complete', exact: true }).click(); + await page.getByRole('button', { name: 'Create account', exact: true }).click(); await page.waitForURL('/unverified-account'); diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index c82e47f68..eba5f33f1 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -83,10 +83,21 @@ export default defineConfig({ testMatch: /e2e\/api\/.*\.spec\.ts/, workers: 10, // Limited by DB connections before it gets flakey. }, - // Run UI Tests + // License tests that share a single license file - must run serially + { + name: 'license', + testMatch: /e2e\/license\/.*\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1920, height: 1200 }, + }, + workers: 1, // Must run serially since they share a license file + }, + // Run UI Tests (excluding license tests which have their own project) { name: 'ui', testMatch: /e2e\/(?!api\/).*\.spec\.ts/, + testIgnore: /e2e\/license\/.*\.spec\.ts/, use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1200 }, diff --git a/packages/assets/fixtures/auto-placement/arbitrary-placeholders.pdf b/packages/assets/fixtures/auto-placement/arbitrary-placeholders.pdf new file mode 100644 index 000000000..731c39211 Binary files /dev/null and b/packages/assets/fixtures/auto-placement/arbitrary-placeholders.pdf differ diff --git a/packages/assets/fixtures/auto-placement/base-project-proposal.pdf b/packages/assets/fixtures/auto-placement/base-project-proposal.pdf new file mode 100644 index 000000000..cb985f0ce Binary files /dev/null and b/packages/assets/fixtures/auto-placement/base-project-proposal.pdf differ diff --git a/packages/assets/fixtures/auto-placement/field-type-only.pdf b/packages/assets/fixtures/auto-placement/field-type-only.pdf new file mode 100644 index 000000000..836d65db7 Binary files /dev/null and b/packages/assets/fixtures/auto-placement/field-type-only.pdf differ diff --git a/packages/assets/fixtures/auto-placement/invalid-field-type.pdf b/packages/assets/fixtures/auto-placement/invalid-field-type.pdf new file mode 100644 index 000000000..faa6a6802 Binary files /dev/null and b/packages/assets/fixtures/auto-placement/invalid-field-type.pdf differ diff --git a/packages/assets/fixtures/auto-placement/no-recipient-placeholders.pdf b/packages/assets/fixtures/auto-placement/no-recipient-placeholders.pdf new file mode 100644 index 000000000..76596c946 Binary files /dev/null and b/packages/assets/fixtures/auto-placement/no-recipient-placeholders.pdf differ diff --git a/packages/assets/fixtures/auto-placement/project-proposal-multiple-fields-and-recipients.pdf b/packages/assets/fixtures/auto-placement/project-proposal-multiple-fields-and-recipients.pdf new file mode 100644 index 000000000..1a6a244a1 Binary files /dev/null and b/packages/assets/fixtures/auto-placement/project-proposal-multiple-fields-and-recipients.pdf differ diff --git a/packages/assets/fixtures/auto-placement/project-proposal-single-recipient.pdf b/packages/assets/fixtures/auto-placement/project-proposal-single-recipient.pdf new file mode 100644 index 000000000..139d01ff9 Binary files /dev/null and b/packages/assets/fixtures/auto-placement/project-proposal-single-recipient.pdf differ diff --git a/packages/auth/server/lib/utils/handle-oauth-authorize-url.ts b/packages/auth/server/lib/utils/handle-oauth-authorize-url.ts index f62d27fa9..047543882 100644 --- a/packages/auth/server/lib/utils/handle-oauth-authorize-url.ts +++ b/packages/auth/server/lib/utils/handle-oauth-authorize-url.ts @@ -30,11 +30,17 @@ type HandleOAuthAuthorizeUrlOptions = { prompt?: 'none' | 'login' | 'consent' | 'select_account'; }; +const isOidcPrompt = (value: unknown): value is HandleOAuthAuthorizeUrlOptions['prompt'] => { + return value === 'none' || value === 'login' || value === 'consent' || value === 'select_account'; +}; + const oauthCookieMaxAge = 60 * 10; // 10 minutes. export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => { const { c, clientOptions, redirectPath } = options; + let prompt = options.prompt ?? 'login'; + if (!clientOptions.clientId || !clientOptions.clientSecret) { throw new AppError(AppErrorCode.NOT_SETUP); } @@ -63,12 +69,12 @@ export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOp ); // Pass the prompt to the authorization endpoint. - if (process.env.NEXT_PRIVATE_OIDC_PROMPT !== '') { - const prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT ?? 'login'; - - url.searchParams.append('prompt', prompt); + if (process.env.NEXT_PRIVATE_OIDC_PROMPT && isOidcPrompt(process.env.NEXT_PRIVATE_OIDC_PROMPT)) { + prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT; } + url.searchParams.set('prompt', prompt); + setCookie(c, `${clientOptions.id}_oauth_state`, state, { ...sessionCookieOptions, sameSite: 'lax', diff --git a/packages/ee/FEATURES b/packages/ee/FEATURES index c557e79ef..50896cb3e 100644 --- a/packages/ee/FEATURES +++ b/packages/ee/FEATURES @@ -2,6 +2,7 @@ This file lists all features currently licensed under the Documenso Enterprise E Copyright (c) 2023 Documenso, Inc - The Stripe Billing Module +- Organisation Authentication Portal - Document Action Reauthentication (Passkeys and 2FA) - 21 CFR - Email domains diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index 26f6c6203..22b96fd06 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -70,7 +70,7 @@ export const getServerLimits = async ({ } // Early return for users with an expired subscription. - if (subscription && subscription.status !== SubscriptionStatus.ACTIVE) { + if (subscription && subscription.status === SubscriptionStatus.INACTIVE) { return { quota: INACTIVE_PLAN_LIMITS, remaining: INACTIVE_PLAN_LIMITS, diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index 2331126ea..ae27a012a 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -383,6 +383,10 @@ const decorateAndSignPdf = async ({ } } + // Should never run into issues with this flatten since all + // arcoFields are created by pdf-lib itself. + legacy_pdfLibDoc.getForm().flatten(); + await pdfDoc.reload(await legacy_pdfLibDoc.save()); } diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index cb60bdca5..f810389e7 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -10,6 +10,9 @@ import { } from '@prisma/client'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { PlaceholderInfo } from '@documenso/lib/server-only/pdf/auto-place-fields'; +import { convertPlaceholdersToFieldInputs } from '@documenso/lib/server-only/pdf/auto-place-fields'; +import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers'; import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf'; import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; @@ -68,7 +71,12 @@ export type CreateEnvelopeOptions = { type: EnvelopeType; title: string; externalId?: string; - envelopeItems: { title?: string; documentDataId: string; order?: number }[]; + envelopeItems: { + title?: string; + documentDataId: string; + order?: number; + placeholders?: PlaceholderInfo[]; + }[]; formValues?: TDocumentFormValues; userTimezone?: string; @@ -164,8 +172,7 @@ export const createEnvelope = async ({ }); } - let envelopeItems: { title?: string; documentDataId: string; order?: number }[] = - data.envelopeItems; + let envelopeItems = data.envelopeItems; // Todo: Envelopes - Remove if (normalizePdf) { @@ -298,7 +305,7 @@ export const createEnvelope = async ({ const delegatedOwner = await getValidatedDelegatedOwner(); const envelopeOwnerId = delegatedOwner?.id ?? userId; - return await prisma.$transaction(async (tx) => { + const createdEnvelope = await prisma.$transaction(async (tx) => { const envelope = await tx.envelope.create({ data: { id: prefixedId('envelope'), @@ -423,6 +430,124 @@ export const createEnvelope = async ({ }), ); + // Create fields from PDF placeholders (extracted at upload time). + const itemsWithPlaceholders = envelopeItems.filter( + (item) => item.placeholders && item.placeholders.length > 0, + ); + + if (itemsWithPlaceholders.length > 0) { + // Collect all unique recipient placeholder references (e.g. "r1", "r2"). + const allPlaceholders = itemsWithPlaceholders.flatMap((item) => item.placeholders ?? []); + const uniqueRecipientRefs = new Map(); + + for (const p of allPlaceholders) { + const match = p.recipient.match(/^r(\d+)$/i); + + if (match) { + const index = Number(match[1]); + + if (!uniqueRecipientRefs.has(index)) { + uniqueRecipientRefs.set(index, `Recipient ${index}`); + } + } + } + + // Fetch existing recipients (may have been created above from data.recipients or defaults). + let availableRecipients = await tx.recipient.findMany({ + where: { envelopeId: envelope.id }, + select: { id: true, email: true }, + }); + + const shouldCreatePlaceholderRecipients = + (!data.recipients || data.recipients.length === 0) && uniqueRecipientRefs.size > 0; + + // If recipients were not provided, create placeholder recipients even when defaults exist. + if (shouldCreatePlaceholderRecipients) { + const existingRecipientEmails = new Set( + availableRecipients.map((recipient) => recipient.email.toLowerCase()), + ); + + const placeholderRecipients = Array.from( + uniqueRecipientRefs.entries(), + ([recipientIndex, name]) => ({ + envelopeId: envelope.id, + email: `recipient.${recipientIndex}@documenso.com`, + name, + role: RecipientRole.SIGNER, + signingOrder: recipientIndex, + token: nanoid(), + sendStatus: SendStatus.NOT_SENT, + signingStatus: SigningStatus.NOT_SIGNED, + }), + ).filter((recipient) => !existingRecipientEmails.has(recipient.email.toLowerCase())); + + if (placeholderRecipients.length > 0) { + await tx.recipient.createMany({ + data: placeholderRecipients, + }); + + // eslint-disable-next-line require-atomic-updates + availableRecipients = await tx.recipient.findMany({ + where: { envelopeId: envelope.id }, + select: { id: true, email: true }, + }); + } + } + + for (const item of itemsWithPlaceholders) { + const envelopeItem = envelope.envelopeItems.find( + (ei) => ei.documentDataId === item.documentDataId, + ); + + if (!envelopeItem) { + continue; + } + + const fieldsToCreate = convertPlaceholdersToFieldInputs( + item.placeholders ?? [], + (recipientPlaceholder, placeholder) => + findRecipientByPlaceholder( + recipientPlaceholder, + placeholder, + data.recipients && data.recipients.length > 0 + ? data.recipients.map((r) => { + const found = availableRecipients.find((cr) => cr.email === r.email); + + if (!found) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: `Recipient not found for email: ${r.email}`, + }); + } + + return found; + }) + : undefined, + availableRecipients, + ), + envelopeItem.id, + ); + + if (fieldsToCreate.length > 0) { + await tx.field.createMany({ + data: fieldsToCreate.map((field) => ({ + envelopeId: envelope.id, + envelopeItemId: envelopeItem.id, + recipientId: field.recipientId, + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: '', + inserted: false, + fieldMeta: field.fieldMeta || undefined, + })), + }); + } + } + } + const createdEnvelope = await tx.envelope.findFirst({ where: { id: envelope.id, @@ -432,8 +557,12 @@ export const createEnvelope = async ({ recipients: true, fields: true, folder: true, - envelopeItems: true, envelopeAttachments: true, + envelopeItems: { + include: { + documentData: true, + }, + }, }, }); @@ -491,4 +620,6 @@ export const createEnvelope = async ({ return createdEnvelope; }); + + return createdEnvelope; }; diff --git a/packages/lib/server-only/field/create-envelope-fields.ts b/packages/lib/server-only/field/create-envelope-fields.ts index 3412d1e4a..f593bf510 100644 --- a/packages/lib/server-only/field/create-envelope-fields.ts +++ b/packages/lib/server-only/field/create-envelope-fields.ts @@ -1,8 +1,11 @@ +import { PDF } from '@libpdf/core'; import { EnvelopeType } from '@prisma/client'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { TFieldAndMeta } from '@documenso/lib/types/field-meta'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server'; +import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; @@ -11,30 +14,53 @@ import type { EnvelopeIdOptions } from '../../utils/envelope'; import { mapFieldToLegacyField } from '../../utils/fields'; import { canRecipientFieldsBeModified } from '../../utils/recipients'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; +import { type BoundingBox, whiteoutRegions } from '../pdf/auto-place-fields'; + +type CoordinatePosition = { + page: number; + positionX: number; + positionY: number; + width: number; + height: number; +}; + +type PlaceholderPosition = { + placeholder: string; + width?: number; + height?: number; + /** + * When true, creates a field at every occurrence of the placeholder in the PDF. + * When false or omitted, only the first occurrence is used. + */ + matchAll?: boolean; +}; + +type FieldPosition = CoordinatePosition | PlaceholderPosition; + +export type CreateEnvelopeFieldInput = TFieldAndMeta & { + /** + * The ID of the item to insert the fields into. + * + * If blank, the first item will be used. + */ + envelopeItemId?: string; + + recipientId: number; +} & FieldPosition; export interface CreateEnvelopeFieldsOptions { userId: number; teamId: number; id: EnvelopeIdOptions; - fields: (TFieldAndMeta & { - /** - * The ID of the item to insert the fields into. - * - * If blank, the first item will be used. - */ - envelopeItemId?: string; - - recipientId: number; - page: number; - positionX: number; - positionY: number; - width: number; - height: number; - })[]; + fields: CreateEnvelopeFieldInput[]; requestMetadata: ApiRequestMetadata; } +const isPlaceholderPosition = (position: FieldPosition): position is PlaceholderPosition => { + return 'placeholder' in position; +}; + export const createEnvelopeFields = async ({ userId, teamId, @@ -55,8 +81,8 @@ export const createEnvelopeFields = async ({ recipients: true, fields: true, envelopeItems: { - select: { - id: true, + include: { + documentData: true, }, }, }, @@ -82,8 +108,33 @@ export const createEnvelopeFields = async ({ }); } - // Field validation. - const validatedFields = fields.map((field) => { + const hasPlaceholderFields = fields.some((field) => isPlaceholderPosition(field)); + + /* + Cache of loaded PDF documents keyed by envelope item ID. + Only loaded when at least one field uses placeholder positioning. + We keep the full PDF objects so we can both read text and draw white boxes + over resolved placeholders before saving back. + */ + const pdfCache = new Map(); + + if (hasPlaceholderFields) { + for (const item of envelope.envelopeItems) { + const bytes = await getFileServerSide(item.documentData); + const pdfDoc = await PDF.load(new Uint8Array(bytes)); + + pdfCache.set(item.id, pdfDoc); + } + } + + /* + Collect placeholder bounding boxes that need to be whited out, grouped by + envelope item ID. Populated during field resolution below. + */ + const placeholderWhiteouts = new Map>(); + + // Field validation and placeholder resolution. + const validatedFields = fields.flatMap((field) => { const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId); // The item to attach the fields to MUST belong to the document. @@ -111,10 +162,84 @@ export const createEnvelopeFields = async ({ }); } + const envelopeItemId = field.envelopeItemId || firstEnvelopeItem.id; + + /* + Resolve field position(s). Placeholder fields are resolved by searching the + PDF text for the placeholder string and using its bounding box. + When matchAll is true, all occurrences produce fields. + */ + if (isPlaceholderPosition(field)) { + const pdfDoc = pdfCache.get(envelopeItemId); + + if (!pdfDoc) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: `Could not load PDF for envelope item ${envelopeItemId}`, + }); + } + + const matches = pdfDoc.findText(field.placeholder); + + if (matches.length === 0) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Placeholder "${field.placeholder}" not found in PDF`, + }); + } + + const matchesToProcess = field.matchAll ? matches : [matches[0]]; + const pages = pdfDoc.getPages(); + + return matchesToProcess.map((match) => { + const page = pages[match.pageIndex]; + + /* + Record this placeholder's bounding box for whiteout. The bbox is in + the original PDF coordinate system (points, bottom-left origin). + */ + if (!placeholderWhiteouts.has(envelopeItemId)) { + placeholderWhiteouts.set(envelopeItemId, []); + } + + placeholderWhiteouts.get(envelopeItemId)!.push({ + pageIndex: match.pageIndex, + bbox: match.bbox, + }); + + /* + Convert point-based coordinates (bottom-left origin) to percentage-based + coordinates (top-left origin) matching the system's field coordinate format. + */ + const topLeftY = page.height - match.bbox.y - match.bbox.height; + + const widthPercent = field.width ?? (match.bbox.width / page.width) * 100; + const heightPercent = field.height ?? (match.bbox.height / page.height) * 100; + + return { + type: field.type, + fieldMeta: field.fieldMeta, + recipientId: field.recipientId, + envelopeItemId, + recipientEmail: recipient.email, + page: match.pageIndex + 1, + positionX: (match.bbox.x / page.width) * 100, + positionY: (topLeftY / page.height) * 100, + width: widthPercent, + height: heightPercent, + }; + }); + } + return { - ...field, - envelopeItemId: field.envelopeItemId || firstEnvelopeItem.id, // Fallback to first envelope item if no envelope item ID is provided. + type: field.type, + fieldMeta: field.fieldMeta, + recipientId: field.recipientId, + envelopeItemId, recipientEmail: recipient.email, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, }; }); @@ -162,6 +287,39 @@ export const createEnvelopeFields = async ({ return newlyCreatedFields; }); + /* + Draw white rectangles over each resolved placeholder in the PDF to hide the + placeholder text, then persist the modified PDFs back to document storage. + */ + for (const [envelopeItemId, whiteouts] of placeholderWhiteouts) { + const pdfDoc = pdfCache.get(envelopeItemId); + + if (!pdfDoc) { + continue; + } + + whiteoutRegions(pdfDoc, whiteouts); + + const modifiedPdfBytes = await pdfDoc.save(); + + const envelopeItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId); + + if (!envelopeItem) { + continue; + } + + const newDocumentData = await putPdfFileServerSide({ + name: 'document.pdf', + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(Buffer.from(modifiedPdfBytes)), + }); + + await prisma.envelopeItem.update({ + where: { id: envelopeItemId }, + data: { documentDataId: newDocumentData.id }, + }); + } + return { fields: createdFields.map((field) => mapFieldToLegacyField(field, envelope)), }; diff --git a/packages/lib/server-only/folder/find-folders-internal.ts b/packages/lib/server-only/folder/find-folders-internal.ts index ccd894bd1..ea71aa0f1 100644 --- a/packages/lib/server-only/folder/find-folders-internal.ts +++ b/packages/lib/server-only/folder/find-folders-internal.ts @@ -66,12 +66,14 @@ export const findFoldersInternal = async ({ where: { type: EnvelopeType.DOCUMENT, folderId: folder.id, + deletedAt: null, }, }), prisma.envelope.count({ where: { type: EnvelopeType.TEMPLATE, folderId: folder.id, + deletedAt: null, }, }), prisma.folder.count({ diff --git a/packages/lib/server-only/license/license-client.ts b/packages/lib/server-only/license/license-client.ts new file mode 100644 index 000000000..4080660b7 --- /dev/null +++ b/packages/lib/server-only/license/license-client.ts @@ -0,0 +1,245 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { prisma } from '@documenso/prisma'; + +import { IS_BILLING_ENABLED } from '../../constants/app'; +import type { TLicenseClaim } from '../../types/license'; +import { + LICENSE_FILE_NAME, + type TCachedLicense, + type TLicenseResponse, + ZCachedLicenseSchema, + ZLicenseResponseSchema, +} from '../../types/license'; +import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '../../types/subscription'; +import { env } from '../../utils/env'; + +const LICENSE_KEY = env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY'); +const LICENSE_SERVER_URL = + env('INTERNAL_OVERRIDE_LICENSE_SERVER_URL') || 'https://license.documenso.com'; + +declare global { + // eslint-disable-next-line no-var + var __documenso_license_client__: LicenseClient | undefined; +} + +export class LicenseClient { + /** + * We cache the license in memory incase there is permission issues with + * retrieving the license from the local file system. + */ + private cachedLicense: TCachedLicense | null = null; + + private constructor() {} + + /** + * Start the license client. + * + * This will ping the license server with the configured license key and store + * the response locally in a JSON file. + * + * Uses globalThis to store the singleton instance so that it's shared across + * different bundles (e.g. Hono and Remix) at runtime. + */ + public static async start(): Promise { + if (globalThis.__documenso_license_client__) { + return; + } + + const instance = new LicenseClient(); + + globalThis.__documenso_license_client__ = instance; + + try { + await instance.initialize(); + } catch (err) { + // Do nothing. + console.error('[License] Failed to verify license:', err); + } + } + + /** + * Get the current license client instance. + * + * Returns the shared instance from globalThis, ensuring both Hono and Remix + * bundles access the same instance. + */ + public static getInstance(): LicenseClient | null { + return globalThis.__documenso_license_client__ ?? null; + } + + public async getCachedLicense(): Promise { + if (this.cachedLicense) { + return this.cachedLicense; + } + + const localLicenseFile = await this.loadFromFile(); + + return localLicenseFile; + } + + /** + * Force resync the license from the license server. + * + * This will re-ping the license server and update the cached license file. + */ + public async resync(): Promise { + await this.initialize(); + } + + private async initialize(): Promise { + console.log('[License] Checking license with server...'); + + const cachedLicense = await this.loadFromFile(); + + if (cachedLicense) { + this.cachedLicense = cachedLicense; + } + + let response: TLicenseResponse | null = null; + + try { + response = await this.pingLicenseServer(); + } catch (err) { + // If server is not responding, or erroring, use the cached license. + console.warn('[License] License server not responding, using cached license.'); + console.error(err); + return; + } + + const allowedFlags = response?.data?.flags || {}; + + // Check for unauthorized flag usage + const unauthorizedFlagUsage = await this.checkUnauthorizedFlagUsage(allowedFlags); + + if (unauthorizedFlagUsage) { + console.warn('[License] Found unauthorized flag usage.'); + } + + let status: TCachedLicense['derivedStatus'] = 'NOT_FOUND'; + + if (response?.data?.status) { + status = response.data.status; + } + + if (unauthorizedFlagUsage) { + status = 'UNAUTHORIZED'; + } + + const data: TCachedLicense = { + lastChecked: new Date().toISOString(), + license: response?.data || null, + requestedLicenseKey: LICENSE_KEY, + unauthorizedFlagUsage, + derivedStatus: status, + }; + + this.cachedLicense = data; + await this.saveToFile(data); + + console.log('[License] License check completed successfully.'); + console.log(`[License] Unauthorized Flag Usage: ${unauthorizedFlagUsage ? 'Yes' : 'No'}`); + console.log(`[License] Derived Status: ${status}`); + console.log(`[License] Status: ${response?.data?.status}`); + console.log(`[License] Flags: ${JSON.stringify(allowedFlags)}`); + } + + /** + * Ping the license server to get the license response. + * + * If license not found returns null. + */ + private async pingLicenseServer(): Promise { + if (!LICENSE_KEY) { + return null; + } + + const endpoint = new URL('api/license', LICENSE_SERVER_URL).toString(); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ license: LICENSE_KEY }), + }); + + if (!response.ok) { + throw new Error(`License server returned ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + return ZLicenseResponseSchema.parse(data); + } + + private async saveToFile(data: TCachedLicense): Promise { + const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME); + + try { + await fs.writeFile(licenseFilePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (error) { + console.error('[License] Failed to save license file:', error); + } + } + + private async loadFromFile(): Promise { + const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME); + + try { + const fileContents = await fs.readFile(licenseFilePath, 'utf-8'); + + return ZCachedLicenseSchema.parse(JSON.parse(fileContents)); + } catch { + return null; + } + } + + /** + * Check if any organisation claims are using flags that are not permitted by the current license. + */ + private async checkUnauthorizedFlagUsage(licenseFlags: Partial): Promise { + // Get flags that are NOT permitted by the license by subtracting the allowed flags from the license flags. + const disallowedFlags = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).filter( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (flag) => flag.isEnterprise && !licenseFlags[flag.key as keyof TLicenseClaim], + ); + + let unauthorizedFlagUsage = false; + + if (IS_BILLING_ENABLED() && !licenseFlags.billing) { + unauthorizedFlagUsage = true; + } + + try { + const organisationWithUnauthorizedFlags = await prisma.organisationClaim.findFirst({ + where: { + OR: disallowedFlags.map((flag) => ({ + flags: { + path: [flag.key], + equals: true, + }, + })), + }, + select: { + id: true, + organisation: { + select: { + id: true, + }, + }, + flags: true, + }, + }); + + if (organisationWithUnauthorizedFlags) { + unauthorizedFlagUsage = true; + } + } catch (error) { + console.error('[License] Failed to check unauthorized flag usage:', error); + } + + return unauthorizedFlagUsage; + } +} diff --git a/packages/lib/server-only/pdf/auto-place-fields.ts b/packages/lib/server-only/pdf/auto-place-fields.ts new file mode 100644 index 000000000..c6b76d00e --- /dev/null +++ b/packages/lib/server-only/pdf/auto-place-fields.ts @@ -0,0 +1,237 @@ +import { PDF, rgb } from '@libpdf/core'; +import type { FieldType, Recipient } from '@prisma/client'; + +import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; + +import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers'; + +const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g; +const DEFAULT_FIELD_HEIGHT_PERCENT = 2; +const MIN_HEIGHT_THRESHOLD = 0.01; + +export type BoundingBox = { + x: number; + y: number; + width: number; + height: number; +}; + +/** + * Draw white rectangles over specified regions in a loaded PDF document. + * + * Mutates the PDF in place. Coordinates use bottom-left origin (standard PDF coordinates). + */ +export const whiteoutRegions = ( + pdfDoc: PDF, + regions: Array<{ pageIndex: number; bbox: BoundingBox }>, +): void => { + const pages = pdfDoc.getPages(); + + for (const { pageIndex, bbox } of regions) { + const page = pages[pageIndex]; + + page.drawRectangle({ + x: bbox.x, + y: bbox.y, + width: bbox.width, + height: bbox.height, + color: rgb(1, 1, 1), + borderColor: rgb(1, 1, 1), + borderWidth: 2, + }); + } +}; + +export type PlaceholderInfo = { + placeholder: string; + recipient: string; + fieldAndMeta: TFieldAndMeta; + page: number; + x: number; + y: number; + width: number; + height: number; + pageWidth: number; + pageHeight: number; +}; + +export type FieldToCreate = TFieldAndMeta & { + envelopeItemId?: string; + recipientId: number; + page: number; + positionX: number; + positionY: number; + width: number; + height: number; +}; + +export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise => { + const pdfDoc = await PDF.load(new Uint8Array(pdf)); + + const placeholders: PlaceholderInfo[] = []; + + for (const page of pdfDoc.getPages()) { + const pageWidth = page.width; + const pageHeight = page.height; + + const matches = page.findText(PLACEHOLDER_REGEX); + + for (const match of matches) { + const placeholder = match.text; + + /* + Extract the inner content from the placeholder match. + E.g. '{{SIGNATURE, r1, required=true}}' -> 'SIGNATURE, r1, required=true' + */ + const innerMatch = placeholder.match(/^\{\{([^}]+)\}\}$/); + + if (!innerMatch) { + continue; + } + + const placeholderData = innerMatch[1].split(',').map((property) => property.trim()); + const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData; + + let fieldType: FieldType; + + try { + fieldType = parseFieldTypeFromPlaceholder(fieldTypeString); + } catch { + // Skip placeholders with unrecognized field types. + continue; + } + + /* + A recipient identifier (e.g. "r1", "R2") is required for auto-placement. + Placeholders without an explicit recipient like {{name}} are reserved for + future API use where callers can reference a placeholder by name with + optional dimensions instead of absolute coordinates. + */ + if (!recipientOrMeta || !/^r\d+$/i.test(recipientOrMeta)) { + continue; + } + + const recipient = recipientOrMeta; + + const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('='))); + + const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType); + + const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({ + type: fieldType, + fieldMeta: parsedFieldMeta, + }); + + /* + LibPDF returns bbox in points with bottom-left origin. + Convert Y to top-left origin for consistency with the rest of the system. + */ + const topLeftY = pageHeight - match.bbox.y - match.bbox.height; + + placeholders.push({ + placeholder, + recipient, + fieldAndMeta, + page: page.index + 1, + x: match.bbox.x, + y: topLeftY, + width: match.bbox.width, + height: match.bbox.height, + pageWidth, + pageHeight, + }); + } + } + + return placeholders; +}; + +/** + * Draw white rectangles over placeholder text in a PDF. + * + * Accepts optional pre-extracted placeholders to avoid re-parsing the PDF. + */ +export const removePlaceholdersFromPDF = async ( + pdf: Buffer, + placeholders?: PlaceholderInfo[], +): Promise => { + const resolved = placeholders ?? (await extractPlaceholdersFromPDF(pdf)); + + const pdfDoc = await PDF.load(new Uint8Array(pdf)); + const pages = pdfDoc.getPages(); + + /* + Convert PlaceholderInfo[] to whiteout regions. + PlaceholderInfo uses top-left origin, but whiteoutRegions expects bottom-left. + */ + const regions = resolved.map((p) => { + const page = pages[p.page - 1]; + const bottomLeftY = page.height - p.y - p.height; + + return { + pageIndex: p.page - 1, + bbox: { x: p.x, y: bottomLeftY, width: p.width, height: p.height }, + }; + }); + + whiteoutRegions(pdfDoc, regions); + + const modifiedPdfBytes = await pdfDoc.save(); + + return Buffer.from(modifiedPdfBytes); +}; + +/** + * Extract placeholders from a PDF and remove them from the document. + * + * Returns the cleaned PDF buffer and the extracted placeholders. If no + * placeholders are found the original buffer is returned as-is. + */ +export const extractPdfPlaceholders = async ( + pdf: Buffer, +): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => { + const placeholders = await extractPlaceholdersFromPDF(pdf); + + if (placeholders.length === 0) { + return { cleanedPdf: pdf, placeholders: [] }; + } + + const cleanedPdf = await removePlaceholdersFromPDF(pdf, placeholders); + + return { cleanedPdf, placeholders }; +}; + +/** + * Convert pre-extracted PlaceholderInfo[] to field creation inputs. + * + * Pure data transform — converts point-based coordinates to percentages and + * resolves recipient references via the provided callback. No DB calls. + */ +export const convertPlaceholdersToFieldInputs = ( + placeholders: PlaceholderInfo[], + recipientResolver: (recipientPlaceholder: string, placeholder: string) => Pick, + envelopeItemId?: string, +): FieldToCreate[] => { + return placeholders.map((p) => { + const xPercent = (p.x / p.pageWidth) * 100; + const yPercent = (p.y / p.pageHeight) * 100; + const widthPercent = (p.width / p.pageWidth) * 100; + const heightPercent = (p.height / p.pageHeight) * 100; + + const finalHeightPercent = + heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT; + + const recipient = recipientResolver(p.recipient, p.placeholder); + + return { + ...p.fieldAndMeta, + envelopeItemId, + recipientId: recipient.id, + page: p.page, + positionX: xPercent, + positionY: yPercent, + width: widthPercent, + height: finalHeightPercent, + }; + }); +}; diff --git a/packages/lib/server-only/pdf/helpers.ts b/packages/lib/server-only/pdf/helpers.ts new file mode 100644 index 000000000..2e9a97369 --- /dev/null +++ b/packages/lib/server-only/pdf/helpers.ts @@ -0,0 +1,152 @@ +import { FieldType } from '@prisma/client'; +import type { Recipient } from '@prisma/client'; +import { match } from 'ts-pattern'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; + +type RecipientPlaceholderInfo = { + email: string; + name: string; + recipientIndex: number; +}; + +/* + Parse field type string to FieldType enum. + Normalizes the input (uppercase, trim) and validates it's a valid field type. + This ensures we handle case variations and whitespace, and provides clear error messages. +*/ +export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldType => { + const normalizedType = fieldTypeString.toUpperCase().trim(); + + return match(normalizedType) + .with('SIGNATURE', () => FieldType.SIGNATURE) + .with('FREE_SIGNATURE', () => FieldType.FREE_SIGNATURE) + .with('INITIALS', () => FieldType.INITIALS) + .with('NAME', () => FieldType.NAME) + .with('EMAIL', () => FieldType.EMAIL) + .with('DATE', () => FieldType.DATE) + .with('TEXT', () => FieldType.TEXT) + .with('NUMBER', () => FieldType.NUMBER) + .with('RADIO', () => FieldType.RADIO) + .with('CHECKBOX', () => FieldType.CHECKBOX) + .with('DROPDOWN', () => FieldType.DROPDOWN) + .otherwise(() => { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Invalid field type: ${fieldTypeString}`, + }); + }); +}; + +/* + Transform raw field metadata from placeholder format to schema format. + Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign). + Converts string values to proper types (booleans, numbers). +*/ +export const parseFieldMetaFromPlaceholder = ( + rawFieldMeta: Record, + fieldType: FieldType, +): Record | undefined => { + if (fieldType === FieldType.SIGNATURE || fieldType === FieldType.FREE_SIGNATURE) { + return; + } + + if (Object.keys(rawFieldMeta).length === 0) { + return; + } + + const fieldTypeString = String(fieldType).toLowerCase(); + + const parsedFieldMeta: Record = { + type: fieldTypeString, + }; + + /* + rawFieldMeta is an object with string keys and string values. + It contains string values because the PDF parser returns the values as strings. + + E.g. { 'required': 'true', 'fontSize': '12', 'maxValue': '100', 'minValue': '0', 'characterLimit': '100' } + */ + const rawFieldMetaEntries = Object.entries(rawFieldMeta); + + for (const [property, value] of rawFieldMetaEntries) { + if (property === 'readOnly' || property === 'required') { + parsedFieldMeta[property] = value === 'true'; + } else if ( + property === 'fontSize' || + property === 'maxValue' || + property === 'minValue' || + property === 'characterLimit' + ) { + const numValue = Number(value); + + if (!Number.isNaN(numValue)) { + parsedFieldMeta[property] = numValue; + } + } else { + parsedFieldMeta[property] = value; + } + } + + return parsedFieldMeta; +}; + +const extractRecipientPlaceholder = (placeholder: string): RecipientPlaceholderInfo => { + const indexMatch = placeholder.match(/^r(\d+)$/i); + + if (!indexMatch) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Invalid recipient placeholder format: ${placeholder}. Expected format: r1, r2, r3, etc.`, + }); + } + + const recipientIndex = Number(indexMatch[1]); + + return { + email: `recipient.${recipientIndex}@documenso.com`, + name: `Recipient ${recipientIndex}`, + recipientIndex, + }; +}; + +/* + Finds a recipient based on a placeholder reference. + If recipients array is provided, uses index-based matching (r1 -> recipients[0], etc.). + Otherwise, uses email-based matching from createdRecipients. +*/ +export const findRecipientByPlaceholder = ( + recipientPlaceholder: string, + placeholder: string, + recipients: Pick[] | undefined, + createdRecipients: Pick[], +): Pick => { + if (recipients && recipients.length > 0) { + /* + Map placeholder by index: r1 -> recipients[0], r2 -> recipients[1], etc. + recipientIndex is 1-based, so we subtract 1 to get the array index. + */ + const { recipientIndex } = extractRecipientPlaceholder(recipientPlaceholder); + const recipientArrayIndex = recipientIndex - 1; + + if (recipientArrayIndex < 0 || recipientArrayIndex >= recipients.length) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Recipient placeholder ${recipientPlaceholder} (index ${recipientIndex}) is out of range. Provided ${recipients.length} recipient(s).`, + }); + } + + return recipients[recipientArrayIndex]; + } + + /* + Use email-based matching for placeholder recipients. + */ + const { email } = extractRecipientPlaceholder(recipientPlaceholder); + const recipient = createdRecipients.find((r) => r.email === email); + + if (!recipient) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Could not find recipient ID for placeholder: ${placeholder}`, + }); + } + + return recipient; +}; diff --git a/packages/lib/server-only/pdf/normalize-pdf.ts b/packages/lib/server-only/pdf/normalize-pdf.ts index 4a2ebcd5d..01e5ac108 100644 --- a/packages/lib/server-only/pdf/normalize-pdf.ts +++ b/packages/lib/server-only/pdf/normalize-pdf.ts @@ -28,5 +28,7 @@ export const normalizePdf = async (pdf: Buffer, options: { flattenForm?: boolean pdfDoc.flattenAnnotations(); } - return Buffer.from(await pdfDoc.save()); + const normalizedPdfBytes = await pdfDoc.save(); + + return Buffer.from(normalizedPdfBytes); }; diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 230b0a0e3..bb9ba9db2 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -551,6 +551,7 @@ export const createDocumentFromTemplate = async ({ visibility: template.visibility || settings.documentVisibility, useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false, documentMetaId: documentMeta.id, + formValues: formValues ?? undefined, recipients: { createMany: { data: allFinalRecipients.map((recipient) => { diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts index 3a46dd4b4..621459255 100644 --- a/packages/lib/server-only/template/find-templates.ts +++ b/packages/lib/server-only/template/find-templates.ts @@ -79,6 +79,7 @@ export const findTemplates = async ({ }, }, skip: Math.max(page - 1, 0) * perPage, + take: perPage, orderBy: { createdAt: 'desc', }, diff --git a/packages/lib/translations/de/web.po b/packages/lib/translations/de/web.po index ccb8924d4..313a8050a 100644 --- a/packages/lib/translations/de/web.po +++ b/packages/lib/translations/de/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: de\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: German\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -179,6 +179,16 @@ msgstr "{0, plural, one {Seite {1} von {2} - # Empfänger gefunden} other {Seite msgid "{0, plural, one {Recipient added} other {Recipients added}}" msgstr "{0, plural, one {Empfänger hinzugefügt} other {Empfänger hinzugefügt}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" +msgstr "" + #. placeholder {0}: pendingRecipients.length #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}" @@ -194,6 +204,16 @@ msgstr "{0, plural, one {Wir haben # Feld in Ihrem Dokument gefunden.} other {Wi msgid "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" msgstr "{0, plural, one {Wir haben # Empfänger in Ihrem Dokument gefunden.} other {Wir haben # Empfänger in Ihrem Dokument gefunden.}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" +msgstr "" + #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #. placeholder {0}: route.label #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -223,6 +243,17 @@ msgstr "{0} hat Sie eingeladen, das Dokument \"{1}\" {recipientActionVerb}." msgid "{0} invited you to {recipientActionVerb} a document" msgstr "{0} hat dich eingeladen, ein Dokument {recipientActionVerb}" +#. placeholder {0}: result.deletedCount +#. placeholder {1}: result.failedIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) deleted. {1} item(s) could not be deleted." +msgstr "" + +#. placeholder {0}: result.deletedCount +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) have been deleted." +msgstr "" + #. placeholder {0}: remaining.documents #. placeholder {1}: quota.documents #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -462,6 +493,10 @@ msgstr "{recipientReference} hat {documentName} unterschrieben" msgid "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" msgstr "{remaningLength, plural, one {# Zeichen verbleibend} other {# Zeichen verbleibend}}" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "{selectedCount} selected" +msgstr "" + #: packages/email/template-components/template-document-rejected.tsx msgid "{signerName} has rejected the document \"{documentName}\"." msgstr "{signerName} hat das Dokument \"{documentName}\" abgelehnt." @@ -1362,11 +1397,16 @@ msgstr "Alle Ordner" msgid "All inserted signatures will be voided" msgstr "Alle eingefügten Unterschriften werden annulliert" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "All items must be of the same type." +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly." msgstr "Alle Empfänger haben unterschrieben. Das Dokument wird verarbeitet und Sie erhalten in Kürze eine Kopie per E-Mail." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "All recipients will be notified" msgstr "Alle Empfänger werden benachrichtigt" @@ -1528,6 +1568,10 @@ msgstr "Ein Fehler ist aufgetreten, während das Dokument aus der Vorlage erstel msgid "An error occurred while creating the webhook. Please try again." msgstr "Ein Fehler ist aufgetreten, während der Webhook erstellt wurde. Bitte versuchen Sie es erneut." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "An error occurred while deleting the items." +msgstr "" + #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx msgid "An error occurred while deleting the user." msgstr "Ein Fehler ist beim Löschen des Benutzers aufgetreten." @@ -1560,6 +1604,10 @@ msgstr "Ein Fehler ist beim Laden des Dokuments aufgetreten." msgid "An error occurred while moving the document." msgstr "Ein Fehler ist aufgetreten, während das Dokument verschoben wurde." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "An error occurred while moving the items." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "An error occurred while moving the template." msgstr "Ein Fehler ist aufgetreten, während die Vorlage verschoben wurde." @@ -1951,7 +1999,7 @@ msgstr "Audit-Protokoll" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Audit-Log-Details" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -2165,6 +2213,8 @@ msgstr "Kann vorbereiten" #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -2372,6 +2422,10 @@ msgstr "Ansprüche" msgid "Clear filters" msgstr "Filter löschen" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "Clear selection" +msgstr "" + #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Clear Signature" msgstr "Unterschrift löschen" @@ -3115,7 +3169,7 @@ msgstr "Standardorganisation Rolle für neue Benutzer" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Standardempfänger" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -3141,6 +3195,7 @@ msgstr "löschen" #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -3159,6 +3214,7 @@ msgstr "löschen" #: apps/remix/app/components/general/folder/folder-card.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -3213,6 +3269,10 @@ msgstr "Dokument löschen" msgid "Delete Document" msgstr "Dokument löschen" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Documents" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx msgid "Delete email" msgstr "E-Mail löschen" @@ -3262,6 +3322,10 @@ msgstr "Teamgruppe löschen" msgid "Delete Template" msgstr "Vorlage löschen" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Templates" +msgstr "" + #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "Delete the document. This action is irreversible so proceed with caution." msgstr "Löschen Sie das Dokument. Diese Aktion ist irreversibel, daher seien Sie vorsichtig." @@ -3399,6 +3463,10 @@ msgstr "Die direkte Links-Signatur wurde aktiviert" msgid "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." msgstr "Direkte Linkvorlagen enthalten einen dynamischen Empfänger-Platzhalter. Jeder, der Zugriff auf diesen Link hat, kann das Dokument unterzeichnen, und es wird dann auf Ihrer Dokumenten-Seite angezeigt." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Direct links associated with templates will be removed" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link deleted" msgstr "Direkter Vorlagenlink gelöscht" @@ -3872,6 +3940,14 @@ msgstr "Dokumente erstellt" msgid "Documents created from template" msgstr "Dokumente erstellt aus Vorlage" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Documents Received" msgstr "Dokumente empfangen" @@ -4476,6 +4552,7 @@ msgstr "Umschlag aktualisiert" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -4782,6 +4859,7 @@ msgid "Fill in the details to create a new subscription claim." msgstr "Füllen Sie die Details aus, um einen neuen Abonnementsanspruch zu erstellen." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Folder" msgstr "Ordner" @@ -5104,6 +5182,7 @@ msgid "Home" msgstr "Startseite" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Home (No Folder)" msgstr "Startseite (kein Ordner)" @@ -5902,6 +5981,7 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Monatlich aktive Benutzer: Benutzer, die mindestens eines ihrer Dokumente abgeschlossen haben" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -5916,6 +5996,10 @@ msgstr "\"{templateTitle}\" in einen Ordner verschieben" msgid "Move Document to Folder" msgstr "Dokument in Ordner verschieben" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Documents to Folder" +msgstr "" + #: apps/remix/app/components/dialogs/folder-move-dialog.tsx msgid "Move Folder" msgstr "Ordner verschieben" @@ -5924,7 +6008,12 @@ msgstr "Ordner verschieben" msgid "Move Template to Folder" msgstr "Vorlage in Ordner verschieben" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Templates to Folder" +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx msgid "Move to Folder" msgstr "In Ordner verschieben" @@ -6061,6 +6150,7 @@ msgid "No fields were detected in your document." msgstr "In Ihrem Dokument wurden keine Felder erkannt." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "No folders found" msgstr "Keine Ordner gefunden" @@ -6238,6 +6328,7 @@ msgid "On this page, you can create new Webhooks and manage the existing ones." msgstr "Auf dieser Seite können Sie neue Webhooks erstellen und die vorhandenen verwalten." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" msgstr "Sobald dies bestätigt ist, wird Folgendes geschehen:" @@ -6612,6 +6703,10 @@ msgstr "Ausstehende Dokumente" msgid "Pending Documents" msgstr "Ausstehende Dokumente" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Pending documents will have their signing process cancelled" +msgstr "" + #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" msgstr "Ausstehende Einladungen" @@ -6788,6 +6883,7 @@ msgid "Please note that proceeding will remove direct linking recipient and turn msgstr "Bitte beachten Sie, dass das Fortfahren den direkten Linkempfänger entfernt und ihn in einen Platzhalter umwandelt." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Please note that this action is <0>irreversible." msgstr "Bitte beachten Sie, dass diese Aktion <0>irreversibel ist." @@ -7136,7 +7232,7 @@ msgstr "Empfängermetriken" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Empfänger, die neuen Dokumenten automatisch hinzugefügt werden." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7602,6 +7698,7 @@ msgid "Search documents..." msgstr "Dokumente suchen..." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -7679,6 +7776,11 @@ msgstr "Wählen Sie eine Zeitzone aus" msgid "Select access methods" msgstr "Zugriffsmethoden auswählen" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select all" +msgstr "" + #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Select an event type" msgstr "Wählen Sie einen Ereignistyp aus" @@ -7758,6 +7860,11 @@ msgstr "Passkey auswählen" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" +msgstr "Empfänger auswählen" + +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select row" msgstr "" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx @@ -7806,11 +7913,23 @@ msgstr "Vertikale Ausrichtung auswählen" msgid "Select visibility" msgstr "Sichtbarkeit auswählen" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected documents will be permanently deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Selected items have been moved." +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx msgid "Selected Recipient" msgstr "Ausgewählter Empfänger" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected templates will be permanently deleted" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8812,6 +8931,14 @@ msgstr "Vorlage hochgeladen" msgid "Templates" msgstr "Vorlagen" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Test" msgstr "Test" @@ -9009,6 +9136,10 @@ msgstr "Der Order, den Sie verschieben möchten, existiert nicht." msgid "The folder you are trying to move the document to does not exist." msgstr "Der Ordner, in den Sie das Dokument verschieben möchten, existiert nicht." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "The folder you are trying to move the items to does not exist." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "The folder you are trying to move the template to does not exist." msgstr "Der Ordner, in den Sie die Vorlage verschieben möchten, existiert nicht." @@ -10396,7 +10527,7 @@ msgstr "Einladungen ansehen" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "JSON anzeigen" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11103,6 +11234,10 @@ msgstr "Sie aktualisieren derzeit den <0>{passkeyName} Passkey." msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "Sie aktualisieren derzeit die Teamgruppe <0>{teamGroupName}." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "You are not allowed to move these items." +msgstr "" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "You are not allowed to move this document." msgstr "Sie dürfen dieses Dokument nicht verschieben." diff --git a/packages/lib/translations/en/web.po b/packages/lib/translations/en/web.po index 8ca99e40a..a3e6b311e 100644 --- a/packages/lib/translations/en/web.po +++ b/packages/lib/translations/en/web.po @@ -174,6 +174,16 @@ msgstr "{0, plural, one {Page {1} of {2} - # recipient found} other {Page {3} of msgid "{0, plural, one {Recipient added} other {Recipients added}}" msgstr "{0, plural, one {Recipient added} other {Recipients added}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" +msgstr "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" +msgstr "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" + #. placeholder {0}: pendingRecipients.length #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}" @@ -189,6 +199,16 @@ msgstr "{0, plural, one {We found # field in your document.} other {We found # f msgid "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" msgstr "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" +msgstr "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" +msgstr "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" + #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #. placeholder {0}: route.label #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -218,6 +238,17 @@ msgstr "{0} has invited you to {recipientActionVerb} the document \"{1}\"." msgid "{0} invited you to {recipientActionVerb} a document" msgstr "{0} invited you to {recipientActionVerb} a document" +#. placeholder {0}: result.deletedCount +#. placeholder {1}: result.failedIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) deleted. {1} item(s) could not be deleted." +msgstr "{0} item(s) deleted. {1} item(s) could not be deleted." + +#. placeholder {0}: result.deletedCount +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) have been deleted." +msgstr "{0} item(s) have been deleted." + #. placeholder {0}: remaining.documents #. placeholder {1}: quota.documents #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -457,6 +488,10 @@ msgstr "{recipientReference} has signed {documentName}" msgid "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" msgstr "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "{selectedCount} selected" +msgstr "{selectedCount} selected" + #: packages/email/template-components/template-document-rejected.tsx msgid "{signerName} has rejected the document \"{documentName}\"." msgstr "{signerName} has rejected the document \"{documentName}\"." @@ -1357,11 +1392,16 @@ msgstr "All Folders" msgid "All inserted signatures will be voided" msgstr "All inserted signatures will be voided" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "All items must be of the same type." +msgstr "All items must be of the same type." + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly." msgstr "All recipients have signed. The document is being processed and you will receive an email copy shortly." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "All recipients will be notified" msgstr "All recipients will be notified" @@ -1523,6 +1563,10 @@ msgstr "An error occurred while creating document from template." msgid "An error occurred while creating the webhook. Please try again." msgstr "An error occurred while creating the webhook. Please try again." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "An error occurred while deleting the items." +msgstr "An error occurred while deleting the items." + #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx msgid "An error occurred while deleting the user." msgstr "An error occurred while deleting the user." @@ -1555,6 +1599,10 @@ msgstr "An error occurred while loading the document." msgid "An error occurred while moving the document." msgstr "An error occurred while moving the document." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "An error occurred while moving the items." +msgstr "An error occurred while moving the items." + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "An error occurred while moving the template." msgstr "An error occurred while moving the template." @@ -2160,6 +2208,8 @@ msgstr "Can prepare" #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -2367,6 +2417,10 @@ msgstr "Claims" msgid "Clear filters" msgstr "Clear filters" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "Clear selection" +msgstr "Clear selection" + #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Clear Signature" msgstr "Clear Signature" @@ -3136,6 +3190,7 @@ msgstr "delete" #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -3154,6 +3209,7 @@ msgstr "delete" #: apps/remix/app/components/general/folder/folder-card.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -3208,6 +3264,10 @@ msgstr "Delete document" msgid "Delete Document" msgstr "Delete Document" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Documents" +msgstr "Delete Documents" + #: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx msgid "Delete email" msgstr "Delete email" @@ -3257,6 +3317,10 @@ msgstr "Delete team group" msgid "Delete Template" msgstr "Delete Template" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Templates" +msgstr "Delete Templates" + #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "Delete the document. This action is irreversible so proceed with caution." msgstr "Delete the document. This action is irreversible so proceed with caution." @@ -3394,6 +3458,10 @@ msgstr "Direct link signing has been enabled" msgid "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." msgstr "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Direct links associated with templates will be removed" +msgstr "Direct links associated with templates will be removed" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link deleted" msgstr "Direct template link deleted" @@ -3867,6 +3935,14 @@ msgstr "Documents Created" msgid "Documents created from template" msgstr "Documents created from template" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents deleted" +msgstr "Documents deleted" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents partially deleted" +msgstr "Documents partially deleted" + #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Documents Received" msgstr "Documents Received" @@ -4471,6 +4547,7 @@ msgstr "Envelope updated" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -4777,6 +4854,7 @@ msgid "Fill in the details to create a new subscription claim." msgstr "Fill in the details to create a new subscription claim." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Folder" msgstr "Folder" @@ -5099,6 +5177,7 @@ msgid "Home" msgstr "Home" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Home (No Folder)" msgstr "Home (No Folder)" @@ -5897,6 +5976,7 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Monthly Active Users: Users that had at least one of their documents completed" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -5911,6 +5991,10 @@ msgstr "Move \"{templateTitle}\" to a folder" msgid "Move Document to Folder" msgstr "Move Document to Folder" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Documents to Folder" +msgstr "Move Documents to Folder" + #: apps/remix/app/components/dialogs/folder-move-dialog.tsx msgid "Move Folder" msgstr "Move Folder" @@ -5919,7 +6003,12 @@ msgstr "Move Folder" msgid "Move Template to Folder" msgstr "Move Template to Folder" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Templates to Folder" +msgstr "Move Templates to Folder" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx msgid "Move to Folder" msgstr "Move to Folder" @@ -6056,6 +6145,7 @@ msgid "No fields were detected in your document." msgstr "No fields were detected in your document." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "No folders found" msgstr "No folders found" @@ -6233,6 +6323,7 @@ msgid "On this page, you can create new Webhooks and manage the existing ones." msgstr "On this page, you can create new Webhooks and manage the existing ones." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" msgstr "Once confirmed, the following will occur:" @@ -6607,6 +6698,10 @@ msgstr "Pending documents" msgid "Pending Documents" msgstr "Pending Documents" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Pending documents will have their signing process cancelled" +msgstr "Pending documents will have their signing process cancelled" + #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" msgstr "Pending invitations" @@ -6783,6 +6878,7 @@ msgid "Please note that proceeding will remove direct linking recipient and turn msgstr "Please note that proceeding will remove direct linking recipient and turn it into a placeholder." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Please note that this action is <0>irreversible." msgstr "Please note that this action is <0>irreversible." @@ -7597,6 +7693,7 @@ msgid "Search documents..." msgstr "Search documents..." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -7674,6 +7771,11 @@ msgstr "Select a time zone" msgid "Select access methods" msgstr "Select access methods" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select all" +msgstr "Select all" + #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Select an event type" msgstr "Select an event type" @@ -7755,6 +7857,11 @@ msgstr "Select passkey" msgid "Select recipients" msgstr "Select recipients" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select row" +msgstr "Select row" + #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: packages/ui/primitives/document-flow/add-settings.tsx @@ -7801,11 +7908,23 @@ msgstr "Select vertical align" msgid "Select visibility" msgstr "Select visibility" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected documents will be permanently deleted" +msgstr "Selected documents will be permanently deleted" + +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Selected items have been moved." +msgstr "Selected items have been moved." + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx msgid "Selected Recipient" msgstr "Selected Recipient" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected templates will be permanently deleted" +msgstr "Selected templates will be permanently deleted" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8807,6 +8926,14 @@ msgstr "Template uploaded" msgid "Templates" msgstr "Templates" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates deleted" +msgstr "Templates deleted" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates partially deleted" +msgstr "Templates partially deleted" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Test" msgstr "Test" @@ -9004,6 +9131,10 @@ msgstr "The folder you are trying to move does not exist." msgid "The folder you are trying to move the document to does not exist." msgstr "The folder you are trying to move the document to does not exist." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "The folder you are trying to move the items to does not exist." +msgstr "The folder you are trying to move the items to does not exist." + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "The folder you are trying to move the template to does not exist." msgstr "The folder you are trying to move the template to does not exist." @@ -11098,6 +11229,10 @@ msgstr "You are currently updating the <0>{passkeyName} passkey." msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "You are currently updating the <0>{teamGroupName} team group." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "You are not allowed to move these items." +msgstr "You are not allowed to move these items." + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "You are not allowed to move this document." msgstr "You are not allowed to move this document." diff --git a/packages/lib/translations/es/web.po b/packages/lib/translations/es/web.po index 8665b3bcb..deff7e07a 100644 --- a/packages/lib/translations/es/web.po +++ b/packages/lib/translations/es/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: es\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Spanish\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -179,6 +179,16 @@ msgstr "{0, plural, one {Página {1} de {2} - se ha encontrado # destinatario} o msgid "{0, plural, one {Recipient added} other {Recipients added}}" msgstr "{0, plural, one {Destinatario añadido} other {Destinatarios añadidos}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" +msgstr "" + #. placeholder {0}: pendingRecipients.length #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}" @@ -194,6 +204,16 @@ msgstr "{0, plural, one {Hemos encontrado # campo en tu documento.} other {Hemos msgid "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" msgstr "{0, plural, one {Hemos encontrado # destinatario en tu documento.} other {Hemos encontrado # destinatarios en tu documento.}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" +msgstr "" + #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #. placeholder {0}: route.label #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -223,6 +243,17 @@ msgstr "{0} te ha invitado a {recipientActionVerb} el documento \"{1}\"." msgid "{0} invited you to {recipientActionVerb} a document" msgstr "{0} te invitó a {recipientActionVerb} un documento" +#. placeholder {0}: result.deletedCount +#. placeholder {1}: result.failedIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) deleted. {1} item(s) could not be deleted." +msgstr "" + +#. placeholder {0}: result.deletedCount +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) have been deleted." +msgstr "" + #. placeholder {0}: remaining.documents #. placeholder {1}: quota.documents #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -462,6 +493,10 @@ msgstr "{recipientReference} ha firmado {documentName}" msgid "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" msgstr "{remaningLength, plural, one {# carácter restante} other {# caracteres restantes}}" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "{selectedCount} selected" +msgstr "" + #: packages/email/template-components/template-document-rejected.tsx msgid "{signerName} has rejected the document \"{documentName}\"." msgstr "{signerName} ha rechazado el documento \"{documentName}\"." @@ -1362,11 +1397,16 @@ msgstr "Todas las carpetas" msgid "All inserted signatures will be voided" msgstr "Todas las firmas insertadas serán anuladas" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "All items must be of the same type." +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly." msgstr "Todos los destinatarios han firmado. El documento se está procesando y recibirás una copia por correo electrónico en breve." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "All recipients will be notified" msgstr "Todos los destinatarios serán notificados" @@ -1528,6 +1568,10 @@ msgstr "Ocurrió un error al crear el documento a partir de la plantilla." msgid "An error occurred while creating the webhook. Please try again." msgstr "Ocurrió un error al crear el webhook. Por favor, intenta de nuevo." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "An error occurred while deleting the items." +msgstr "" + #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx msgid "An error occurred while deleting the user." msgstr "Se produjo un error al eliminar al usuario." @@ -1560,6 +1604,10 @@ msgstr "Se produjo un error al cargar el documento." msgid "An error occurred while moving the document." msgstr "Ocurrió un error al mover el documento." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "An error occurred while moving the items." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "An error occurred while moving the template." msgstr "Ocurrió un error al mover la plantilla." @@ -1951,7 +1999,7 @@ msgstr "Registro de Auditoría" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Detalles del registro de auditoría" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -2165,6 +2213,8 @@ msgstr "Puede preparar" #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -2372,6 +2422,10 @@ msgstr "Reclamaciones" msgid "Clear filters" msgstr "Limpiar filtros" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "Clear selection" +msgstr "" + #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Clear Signature" msgstr "Limpiar firma" @@ -3115,7 +3169,7 @@ msgstr "Rol de organización predeterminado para nuevos usuarios" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Destinatarios predeterminados" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -3141,6 +3195,7 @@ msgstr "eliminar" #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -3159,6 +3214,7 @@ msgstr "eliminar" #: apps/remix/app/components/general/folder/folder-card.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -3213,6 +3269,10 @@ msgstr "Eliminar documento" msgid "Delete Document" msgstr "Eliminar Documento" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Documents" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx msgid "Delete email" msgstr "Eliminar correo" @@ -3262,6 +3322,10 @@ msgstr "Eliminar grupo de equipo" msgid "Delete Template" msgstr "Eliminar Plantilla" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Templates" +msgstr "" + #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "Delete the document. This action is irreversible so proceed with caution." msgstr "Eliminar el documento. Esta acción es irreversible, así que proceda con precaución." @@ -3399,6 +3463,10 @@ msgstr "La firma de enlace directo ha sido habilitada" msgid "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." msgstr "Las plantillas de enlace directo contienen un marcador de posición de destinatario dinámico. Cualquiera que tenga acceso a este enlace puede firmar el documento, y luego aparecerá en su página de documentos." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Direct links associated with templates will be removed" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link deleted" msgstr "Enlace de plantilla directo eliminado" @@ -3872,6 +3940,14 @@ msgstr "Documentos creados" msgid "Documents created from template" msgstr "Documentos creados a partir de la plantilla" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Documents Received" msgstr "Documentos recibidos" @@ -4476,6 +4552,7 @@ msgstr "Sobre actualizado" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -4782,6 +4859,7 @@ msgid "Fill in the details to create a new subscription claim." msgstr "Rellena los detalles para crear una nueva reclamación de suscripción." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Folder" msgstr "Carpeta" @@ -5104,6 +5182,7 @@ msgid "Home" msgstr "Inicio" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Home (No Folder)" msgstr "Inicio (Sin Carpeta)" @@ -5902,6 +5981,7 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Usuarios activos mensuales: Usuarios que completaron al menos uno de sus documentos" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -5916,6 +5996,10 @@ msgstr "Mover \"{templateTitle}\" a una carpeta" msgid "Move Document to Folder" msgstr "Mover Documento a Carpeta" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Documents to Folder" +msgstr "" + #: apps/remix/app/components/dialogs/folder-move-dialog.tsx msgid "Move Folder" msgstr "Mover Carpeta" @@ -5924,7 +6008,12 @@ msgstr "Mover Carpeta" msgid "Move Template to Folder" msgstr "Mover Plantilla a Carpeta" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Templates to Folder" +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx msgid "Move to Folder" msgstr "Mover a Carpeta" @@ -6061,6 +6150,7 @@ msgid "No fields were detected in your document." msgstr "No se detectaron campos en tu documento." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "No folders found" msgstr "No se encontraron carpetas" @@ -6238,6 +6328,7 @@ msgid "On this page, you can create new Webhooks and manage the existing ones." msgstr "En esta página, puedes editar el webhook y sus configuraciones." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" msgstr "Una vez confirmado, ocurrirá lo siguiente:" @@ -6612,6 +6703,10 @@ msgstr "Documentos pendientes" msgid "Pending Documents" msgstr "Documentos Pendientes" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Pending documents will have their signing process cancelled" +msgstr "" + #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" msgstr "Invitaciones pendientes" @@ -6788,6 +6883,7 @@ msgid "Please note that proceeding will remove direct linking recipient and turn msgstr "Por favor, ten en cuenta que proceder eliminará el destinatario de enlace directo y lo convertirá en un marcador de posición." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Please note that this action is <0>irreversible." msgstr "Por favor, ten en cuenta que esta acción es <0>irreversible." @@ -7136,7 +7232,7 @@ msgstr "Métricas de destinatarios" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Destinatarios que se agregarán automáticamente a los nuevos documentos." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7602,6 +7698,7 @@ msgid "Search documents..." msgstr "Buscar documentos..." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -7679,6 +7776,11 @@ msgstr "Seleccione una zona horaria" msgid "Select access methods" msgstr "Seleccione métodos de acceso" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select all" +msgstr "" + #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Select an event type" msgstr "Selecciona un tipo de evento" @@ -7758,6 +7860,11 @@ msgstr "Seleccionar clave de acceso" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" +msgstr "Seleccionar destinatarios" + +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select row" msgstr "" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx @@ -7806,11 +7913,23 @@ msgstr "Seleccionar alineación vertical" msgid "Select visibility" msgstr "Seleccionar visibilidad" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected documents will be permanently deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Selected items have been moved." +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx msgid "Selected Recipient" msgstr "Destinatario seleccionado" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected templates will be permanently deleted" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8812,6 +8931,14 @@ msgstr "Plantilla subida" msgid "Templates" msgstr "Plantillas" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Test" msgstr "Probar" @@ -9009,6 +9136,10 @@ msgstr "La carpeta que intenta mover no existe." msgid "The folder you are trying to move the document to does not exist." msgstr "La carpeta a la que intenta mover el documento no existe." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "The folder you are trying to move the items to does not exist." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "The folder you are trying to move the template to does not exist." msgstr "La carpeta a la que intenta mover la plantilla no existe." @@ -10396,7 +10527,7 @@ msgstr "Ver invitaciones" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "Ver JSON" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11103,6 +11234,10 @@ msgstr "Actualmente estás actualizando la clave <0>{passkeyName}." msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "Actualmente estás actualizando el grupo de equipo <0>{teamGroupName}." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "You are not allowed to move these items." +msgstr "" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "You are not allowed to move this document." msgstr "No tienes permiso para mover este documento." diff --git a/packages/lib/translations/fr/web.po b/packages/lib/translations/fr/web.po index 81e677037..239322929 100644 --- a/packages/lib/translations/fr/web.po +++ b/packages/lib/translations/fr/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: fr\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: French\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" @@ -179,6 +179,16 @@ msgstr "{0, plural, one {Page {1} sur {2} - # destinataire trouvé} other {Page msgid "{0, plural, one {Recipient added} other {Recipients added}}" msgstr "{0, plural, one {Destinataire ajouté} other {Destinataires ajoutés}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" +msgstr "" + #. placeholder {0}: pendingRecipients.length #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}" @@ -194,6 +204,16 @@ msgstr "{0, plural, one {Nous avons trouvé # champ dans votre document.} other msgid "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" msgstr "{0, plural, one {Nous avons trouvé # destinataire dans votre document.} other {Nous avons trouvé # destinataires dans votre document.}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" +msgstr "" + #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #. placeholder {0}: route.label #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -223,6 +243,17 @@ msgstr "{0} vous a invité à {recipientActionVerb} le document \"{1}\"." msgid "{0} invited you to {recipientActionVerb} a document" msgstr "{0} vous a invité à {recipientActionVerb} un document" +#. placeholder {0}: result.deletedCount +#. placeholder {1}: result.failedIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) deleted. {1} item(s) could not be deleted." +msgstr "" + +#. placeholder {0}: result.deletedCount +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) have been deleted." +msgstr "" + #. placeholder {0}: remaining.documents #. placeholder {1}: quota.documents #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -462,6 +493,10 @@ msgstr "{recipientReference} a signé {documentName}" msgid "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" msgstr "{remaningLength, plural, one {# caractère restant} other {# caractères restants}}" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "{selectedCount} selected" +msgstr "" + #: packages/email/template-components/template-document-rejected.tsx msgid "{signerName} has rejected the document \"{documentName}\"." msgstr "{signerName} a rejeté le document \"{documentName}\"." @@ -1362,11 +1397,16 @@ msgstr "Tous les dossiers" msgid "All inserted signatures will be voided" msgstr "Toutes les signatures insérées seront annulées" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "All items must be of the same type." +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly." msgstr "Tous les destinataires ont signé. Le document est en cours de traitement et vous recevrez une copie par e-mail sous peu." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "All recipients will be notified" msgstr "Tous les destinataires seront notifiés" @@ -1528,6 +1568,10 @@ msgstr "Une erreur est survenue lors de la création du document à partir d'un msgid "An error occurred while creating the webhook. Please try again." msgstr "Une erreur est survenue lors de la création du webhook. Veuillez réessayer." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "An error occurred while deleting the items." +msgstr "" + #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx msgid "An error occurred while deleting the user." msgstr "Une erreur s'est produite lors de la suppression de l'utilisateur." @@ -1560,6 +1604,10 @@ msgstr "Une erreur est survenue lors du chargement du document." msgid "An error occurred while moving the document." msgstr "Une erreur est survenue lors du déplacement du document." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "An error occurred while moving the items." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "An error occurred while moving the template." msgstr "Une erreur est survenue lors du déplacement du modèle." @@ -1951,7 +1999,7 @@ msgstr "Journal d'audit" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Détails du journal d’audit" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -2165,6 +2213,8 @@ msgstr "Peut préparer" #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -2372,6 +2422,10 @@ msgstr "Réclamations" msgid "Clear filters" msgstr "Effacer les filtres" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "Clear selection" +msgstr "" + #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Clear Signature" msgstr "Effacer la signature" @@ -3115,7 +3169,7 @@ msgstr "Rôle par défaut de l'organisation pour les nouveaux utilisateurs" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Destinataires par défaut" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -3141,6 +3195,7 @@ msgstr "supprimer" #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -3159,6 +3214,7 @@ msgstr "supprimer" #: apps/remix/app/components/general/folder/folder-card.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -3213,6 +3269,10 @@ msgstr "Supprimer le document" msgid "Delete Document" msgstr "Supprimer le document" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Documents" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx msgid "Delete email" msgstr "Supprimer l'e-mail" @@ -3262,6 +3322,10 @@ msgstr "Supprimer le groupe d'équipe" msgid "Delete Template" msgstr "Supprimer le modèle" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Templates" +msgstr "" + #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "Delete the document. This action is irreversible so proceed with caution." msgstr "Supprimez le document. Cette action est irréversible, soyez prudent." @@ -3399,6 +3463,10 @@ msgstr "La signature de lien direct a été activée" msgid "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." msgstr "Les modèles de lien direct contiennent un espace réservé de destinataire dynamique. Quiconque ayant accès à ce lien peut signer le document, et il apparaîtra ensuite sur votre page de documents." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Direct links associated with templates will be removed" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link deleted" msgstr "Modèle de lien direct supprimé" @@ -3872,6 +3940,14 @@ msgstr "Documents Créés" msgid "Documents created from template" msgstr "Documents créés à partir du modèle" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Documents Received" msgstr "Documents reçus" @@ -4476,6 +4552,7 @@ msgstr "Enveloppe mise à jour" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -4782,6 +4859,7 @@ msgid "Fill in the details to create a new subscription claim." msgstr "Remplissez les détails pour créer une nouvelle réclamation d'abonnement." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Folder" msgstr "Dossier" @@ -5104,6 +5182,7 @@ msgid "Home" msgstr "Accueil" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Home (No Folder)" msgstr "Accueil (Pas de Dossier)" @@ -5902,6 +5981,7 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Utilisateurs actifs mensuels : utilisateurs ayant terminé au moins un de leurs documents" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -5916,6 +5996,10 @@ msgstr "Déplacer «{templateTitle}» vers un dossier" msgid "Move Document to Folder" msgstr "Déplacer le document vers un dossier" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Documents to Folder" +msgstr "" + #: apps/remix/app/components/dialogs/folder-move-dialog.tsx msgid "Move Folder" msgstr "Déplacer le Dossier" @@ -5924,7 +6008,12 @@ msgstr "Déplacer le Dossier" msgid "Move Template to Folder" msgstr "Déplacer le modèle vers un dossier" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Templates to Folder" +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx msgid "Move to Folder" msgstr "Déplacer vers le dossier" @@ -6061,6 +6150,7 @@ msgid "No fields were detected in your document." msgstr "Aucun champ n'a été détecté dans votre document." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "No folders found" msgstr "Aucun dossier trouvé" @@ -6238,6 +6328,7 @@ msgid "On this page, you can create new Webhooks and manage the existing ones." msgstr "Sur cette page, vous pouvez créer de nouveaux webhooks et gérer ceux existants." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" msgstr "Une fois confirmé, les éléments suivants se produiront :" @@ -6612,6 +6703,10 @@ msgstr "Documents en attente" msgid "Pending Documents" msgstr "Documents en attente" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Pending documents will have their signing process cancelled" +msgstr "" + #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" msgstr "Invitations en attente" @@ -6788,6 +6883,7 @@ msgid "Please note that proceeding will remove direct linking recipient and turn msgstr "Veuillez noter que la poursuite supprimera le destinataire de lien direct et le transformera en espace réservé." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Please note that this action is <0>irreversible." msgstr "Veuillez noter que cette action est <0>irréversible." @@ -7136,7 +7232,7 @@ msgstr "Métriques des destinataires" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Les destinataires qui seront automatiquement ajoutés aux nouveaux documents." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7602,6 +7698,7 @@ msgid "Search documents..." msgstr "Rechercher des documents..." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -7679,6 +7776,11 @@ msgstr "Sélectionner un fuseau horaire" msgid "Select access methods" msgstr "Sélectionnez les méthodes d'accès" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select all" +msgstr "" + #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Select an event type" msgstr "Sélectionnez un type d’événement" @@ -7758,6 +7860,11 @@ msgstr "Sélectionner la clé d'authentification" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" +msgstr "Sélectionner des destinataires" + +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select row" msgstr "" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx @@ -7806,11 +7913,23 @@ msgstr "Sélectionner l'alignement vertical" msgid "Select visibility" msgstr "Sélectionner la visibilité" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected documents will be permanently deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Selected items have been moved." +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx msgid "Selected Recipient" msgstr "Destinataire Sélectionné" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected templates will be permanently deleted" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8812,6 +8931,14 @@ msgstr "Modèle de document téléchargé" msgid "Templates" msgstr "Modèles" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Test" msgstr "Test" @@ -9009,6 +9136,10 @@ msgstr "Le dossier que vous essayez de déplacer n'existe pas." msgid "The folder you are trying to move the document to does not exist." msgstr "Le dossier vers lequel vous essayez de déplacer le document n'existe pas." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "The folder you are trying to move the items to does not exist." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "The folder you are trying to move the template to does not exist." msgstr "Le dossier vers lequel vous essayez de déplacer le modèle n'existe pas." @@ -10396,7 +10527,7 @@ msgstr "Voir les invitations" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "Afficher le JSON" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11103,6 +11234,10 @@ msgstr "Vous mettez à jour actuellement la clé de passkey <0>{passkeyName} msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "Vous mettez actuellement à jour le groupe d'équipe <0>{teamGroupName}." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "You are not allowed to move these items." +msgstr "" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "You are not allowed to move this document." msgstr "Vous n'êtes pas autorisé à déplacer ce document." diff --git a/packages/lib/translations/it/web.po b/packages/lib/translations/it/web.po index f3de8fe23..f626d6f4e 100644 --- a/packages/lib/translations/it/web.po +++ b/packages/lib/translations/it/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: it\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Italian\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -179,6 +179,16 @@ msgstr "{0, plural, one {Pagina {1} di {2} - # destinatario trovato} other {Pagi msgid "{0, plural, one {Recipient added} other {Recipients added}}" msgstr "{0, plural, one {Destinatario aggiunto} other {Destinatari aggiunti}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" +msgstr "" + #. placeholder {0}: pendingRecipients.length #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}" @@ -194,6 +204,16 @@ msgstr "{0, plural, one {Abbiamo trovato # campo nel tuo documento.} other {Abbi msgid "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" msgstr "{0, plural, one {Abbiamo trovato # destinatario nel tuo documento.} other {Abbiamo trovato # destinatari nel tuo documento.}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" +msgstr "" + #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #. placeholder {0}: route.label #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -223,6 +243,17 @@ msgstr "{0} ti ha invitato a {recipientActionVerb} il documento \"{1}\"." msgid "{0} invited you to {recipientActionVerb} a document" msgstr "{0} ti ha invitato a {recipientActionVerb} un documento" +#. placeholder {0}: result.deletedCount +#. placeholder {1}: result.failedIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) deleted. {1} item(s) could not be deleted." +msgstr "" + +#. placeholder {0}: result.deletedCount +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) have been deleted." +msgstr "" + #. placeholder {0}: remaining.documents #. placeholder {1}: quota.documents #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -462,6 +493,10 @@ msgstr "{recipientReference} ha firmato {documentName}" msgid "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" msgstr "{remaningLength, plural, one {# carattere rimanente} other {# caratteri rimanenti}}" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "{selectedCount} selected" +msgstr "" + #: packages/email/template-components/template-document-rejected.tsx msgid "{signerName} has rejected the document \"{documentName}\"." msgstr "{signerName} ha rifiutato il documento \"{documentName}\"." @@ -1362,11 +1397,16 @@ msgstr "Tutte le Cartelle" msgid "All inserted signatures will be voided" msgstr "Tutte le firme inserite saranno annullate" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "All items must be of the same type." +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly." msgstr "Tutti i destinatari hanno firmato. Il documento è in fase di elaborazione e a breve riceverai una copia via email." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "All recipients will be notified" msgstr "Tutti i destinatari saranno notificati" @@ -1528,6 +1568,10 @@ msgstr "Si è verificato un errore durante la creazione del documento dal modell msgid "An error occurred while creating the webhook. Please try again." msgstr "Si è verificato un errore durante la creazione del webhook. Prova di nuovo." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "An error occurred while deleting the items." +msgstr "" + #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx msgid "An error occurred while deleting the user." msgstr "Si è verificato un errore durante l'eliminazione dell'utente." @@ -1560,6 +1604,10 @@ msgstr "Si è verificato un errore durante il caricamento del documento." msgid "An error occurred while moving the document." msgstr "Si è verificato un errore durante lo spostamento del documento." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "An error occurred while moving the items." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "An error occurred while moving the template." msgstr "Si è verificato un errore durante lo spostamento del modello." @@ -1951,7 +1999,7 @@ msgstr "Registro di controllo" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Dettagli registro di controllo" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -2165,6 +2213,8 @@ msgstr "Può preparare" #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -2372,6 +2422,10 @@ msgstr "Richieste" msgid "Clear filters" msgstr "Cancella filtri" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "Clear selection" +msgstr "" + #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Clear Signature" msgstr "Cancella firma" @@ -3115,7 +3169,7 @@ msgstr "Ruolo dell'organizzazione predefinito per nuovi utenti" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Destinatari predefiniti" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -3141,6 +3195,7 @@ msgstr "elimina" #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -3159,6 +3214,7 @@ msgstr "elimina" #: apps/remix/app/components/general/folder/folder-card.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -3213,6 +3269,10 @@ msgstr "Elimina documento" msgid "Delete Document" msgstr "Elimina Documento" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Documents" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx msgid "Delete email" msgstr "Elimina email" @@ -3262,6 +3322,10 @@ msgstr "Elimina gruppo di team" msgid "Delete Template" msgstr "Elimina Modello" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Templates" +msgstr "" + #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "Delete the document. This action is irreversible so proceed with caution." msgstr "Elimina il documento. Questa azione è irreversibile quindi procedi con cautela." @@ -3399,6 +3463,10 @@ msgstr "La firma del collegamento diretto è stata abilitata" msgid "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." msgstr "I modelli di collegamento diretto contengono un segnaposto per un destinatario dinamico. Chiunque abbia accesso a questo link può firmare il documento e apparirà successivamente nella tua pagina dei documenti." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Direct links associated with templates will be removed" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link deleted" msgstr "Collegamento diretto al modello eliminato" @@ -3872,6 +3940,14 @@ msgstr "Documenti creati" msgid "Documents created from template" msgstr "Documenti creati da modello" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Documents Received" msgstr "Documenti ricevuti" @@ -4476,6 +4552,7 @@ msgstr "Busta aggiornata" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -4782,6 +4859,7 @@ msgid "Fill in the details to create a new subscription claim." msgstr "Compila i dettagli per creare una nuova rivendicazione di abbonamento." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Folder" msgstr "Cartella" @@ -5104,6 +5182,7 @@ msgid "Home" msgstr "Home" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Home (No Folder)" msgstr "Home (Nessuna Cartella)" @@ -5902,6 +5981,7 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Utenti attivi mensili: Utenti con almeno uno dei loro documenti completati" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -5916,6 +5996,10 @@ msgstr "Sposta \"{templateTitle}\" in una cartella" msgid "Move Document to Folder" msgstr "Sposta documento nella cartella" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Documents to Folder" +msgstr "" + #: apps/remix/app/components/dialogs/folder-move-dialog.tsx msgid "Move Folder" msgstr "Sposta Cartella" @@ -5924,7 +6008,12 @@ msgstr "Sposta Cartella" msgid "Move Template to Folder" msgstr "Sposta modello nella cartella" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Templates to Folder" +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx msgid "Move to Folder" msgstr "Sposta nella cartella" @@ -6061,6 +6150,7 @@ msgid "No fields were detected in your document." msgstr "Nel tuo documento non è stato rilevato alcun campo." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "No folders found" msgstr "Nessuna cartella trovata" @@ -6238,6 +6328,7 @@ msgid "On this page, you can create new Webhooks and manage the existing ones." msgstr "In questa pagina, puoi creare nuovi Webhook e gestire quelli esistenti." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" msgstr "Una volta confermato, si verificherà quanto segue:" @@ -6612,6 +6703,10 @@ msgstr "Documenti in sospeso" msgid "Pending Documents" msgstr "Documenti in sospeso" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Pending documents will have their signing process cancelled" +msgstr "" + #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" msgstr "Inviti in sospeso" @@ -6788,6 +6883,7 @@ msgid "Please note that proceeding will remove direct linking recipient and turn msgstr "Si prega di notare che procedendo si rimuoverà il destinatario del link diretto e si trasformerà in un segnaposto." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Please note that this action is <0>irreversible." msgstr "Si prega di notare che questa azione è <0>irreversibile." @@ -7136,7 +7232,7 @@ msgstr "Metriche dei destinatari" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Destinatari che verranno aggiunti automaticamente ai nuovi documenti." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7602,6 +7698,7 @@ msgid "Search documents..." msgstr "Cerca documenti..." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -7679,6 +7776,11 @@ msgstr "Seleziona un fuso orario" msgid "Select access methods" msgstr "Seleziona i metodi di accesso" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select all" +msgstr "" + #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Select an event type" msgstr "Seleziona un tipo di evento" @@ -7758,6 +7860,11 @@ msgstr "Seleziona una chiave di accesso" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" +msgstr "Seleziona destinatari" + +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select row" msgstr "" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx @@ -7806,11 +7913,23 @@ msgstr "Seleziona allineamento verticale" msgid "Select visibility" msgstr "Seleziona visibilità" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected documents will be permanently deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Selected items have been moved." +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx msgid "Selected Recipient" msgstr "Destinatario selezionato" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected templates will be permanently deleted" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8812,6 +8931,14 @@ msgstr "Modello caricato" msgid "Templates" msgstr "Modelli" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Test" msgstr "Test" @@ -9009,6 +9136,10 @@ msgstr "La cartella che stai cercando di spostare non esiste." msgid "The folder you are trying to move the document to does not exist." msgstr "La cartella in cui stai cercando di spostare il documento non esiste." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "The folder you are trying to move the items to does not exist." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "The folder you are trying to move the template to does not exist." msgstr "La cartella in cui stai cercando di spostare il modello non esiste." @@ -10396,7 +10527,7 @@ msgstr "Visualizza inviti" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "Visualizza JSON" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11103,6 +11234,10 @@ msgstr "Stai attualmente aggiornando la chiave d'accesso <0>{passkeyName}." msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "Stai attualmente aggiornando il gruppo di team <0>{teamGroupName}." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "You are not allowed to move these items." +msgstr "" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "You are not allowed to move this document." msgstr "Non sei autorizzato a spostare questo documento." diff --git a/packages/lib/translations/ja/web.po b/packages/lib/translations/ja/web.po index 7f20c1272..5a6b6450a 100644 --- a/packages/lib/translations/ja/web.po +++ b/packages/lib/translations/ja/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Japanese\n" "Plural-Forms: nplurals=1; plural=0;\n" @@ -179,6 +179,16 @@ msgstr "{0, plural, other {ページ {3}/{4} - # 人の受信者が見つかり msgid "{0, plural, one {Recipient added} other {Recipients added}}" msgstr "{0, plural, other {受信者を追加しました}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" +msgstr "" + #. placeholder {0}: pendingRecipients.length #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}" @@ -194,6 +204,16 @@ msgstr "{0, plural, other {ドキュメント内で # 個のフィールドが msgid "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" msgstr "{0, plural, other {ドキュメント内で # 人の受信者が見つかりました。}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" +msgstr "" + #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #. placeholder {0}: route.label #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -223,6 +243,17 @@ msgstr "{0} からドキュメント「{1}」への{recipientActionVerb}依頼 msgid "{0} invited you to {recipientActionVerb} a document" msgstr "{0} からドキュメントへの{recipientActionVerb}依頼が届いています" +#. placeholder {0}: result.deletedCount +#. placeholder {1}: result.failedIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) deleted. {1} item(s) could not be deleted." +msgstr "" + +#. placeholder {0}: result.deletedCount +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) have been deleted." +msgstr "" + #. placeholder {0}: remaining.documents #. placeholder {1}: quota.documents #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -462,6 +493,10 @@ msgstr "{recipientReference} が {documentName} に署名しました" msgid "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" msgstr "{remaningLength, plural, other {# 文字残り}}" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "{selectedCount} selected" +msgstr "" + #: packages/email/template-components/template-document-rejected.tsx msgid "{signerName} has rejected the document \"{documentName}\"." msgstr "{signerName} がドキュメント「{documentName}」を却下しました。" @@ -1362,11 +1397,16 @@ msgstr "すべてのフォルダ" msgid "All inserted signatures will be voided" msgstr "挿入されたすべての署名は無効になります" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "All items must be of the same type." +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly." msgstr "すべての受信者が署名しました。現在ドキュメントを処理しており、まもなく署名済みドキュメントのコピーがメールで送信されます。" #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "All recipients will be notified" msgstr "すべての受信者に通知されます" @@ -1528,6 +1568,10 @@ msgstr "テンプレートから文書を作成する際にエラーが発生し msgid "An error occurred while creating the webhook. Please try again." msgstr "Webhook の作成中にエラーが発生しました。もう一度お試しください。" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "An error occurred while deleting the items." +msgstr "" + #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx msgid "An error occurred while deleting the user." msgstr "ユーザーの削除中にエラーが発生しました。" @@ -1560,6 +1604,10 @@ msgstr "ドキュメントの読み込み中にエラーが発生しました。 msgid "An error occurred while moving the document." msgstr "文書の移動中にエラーが発生しました。" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "An error occurred while moving the items." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "An error occurred while moving the template." msgstr "テンプレートの移動中にエラーが発生しました。" @@ -1951,7 +1999,7 @@ msgstr "監査ログ" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "監査ログの詳細" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -2165,6 +2213,8 @@ msgstr "準備ができる" #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -2372,6 +2422,10 @@ msgstr "クレーム" msgid "Clear filters" msgstr "フィルターをクリア" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "Clear selection" +msgstr "" + #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Clear Signature" msgstr "署名をクリア" @@ -3115,7 +3169,7 @@ msgstr "新規ユーザーの既定の組織ロール" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "デフォルトの受信者" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -3141,6 +3195,7 @@ msgstr "delete" #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -3159,6 +3214,7 @@ msgstr "delete" #: apps/remix/app/components/general/folder/folder-card.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -3213,6 +3269,10 @@ msgstr "文書を削除" msgid "Delete Document" msgstr "文書を削除" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Documents" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx msgid "Delete email" msgstr "メールを削除" @@ -3262,6 +3322,10 @@ msgstr "チームグループを削除" msgid "Delete Template" msgstr "テンプレートを削除" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Templates" +msgstr "" + #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "Delete the document. This action is irreversible so proceed with caution." msgstr "この文書を削除します。この操作は取り消せないため、十分ご注意ください。" @@ -3399,6 +3463,10 @@ msgstr "ダイレクトリンク署名は有効になりました" msgid "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." msgstr "ダイレクトリンクテンプレートには 1 つの動的受信者プレースホルダーが含まれます。このリンクにアクセスできる人であれば誰でも文書に署名でき、その文書はあなたの文書一覧に表示されます。" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Direct links associated with templates will be removed" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link deleted" msgstr "ダイレクトテンプレートリンクを削除しました" @@ -3872,6 +3940,14 @@ msgstr "作成された文書数" msgid "Documents created from template" msgstr "テンプレートから作成された文書" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Documents Received" msgstr "受信した文書" @@ -4476,6 +4552,7 @@ msgstr "封筒を更新しました" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -4782,6 +4859,7 @@ msgid "Fill in the details to create a new subscription claim." msgstr "新しいサブスクリプションクレームを作成するための詳細を入力してください。" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Folder" msgstr "フォルダ" @@ -5104,6 +5182,7 @@ msgid "Home" msgstr "ホーム" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Home (No Folder)" msgstr "ホーム(フォルダなし)" @@ -5902,6 +5981,7 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "月間アクティブユーザー:1 つ以上の文書が完了したユーザー" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -5916,6 +5996,10 @@ msgstr "「{templateTitle}」をフォルダに移動" msgid "Move Document to Folder" msgstr "ドキュメントをフォルダに移動" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Documents to Folder" +msgstr "" + #: apps/remix/app/components/dialogs/folder-move-dialog.tsx msgid "Move Folder" msgstr "フォルダを移動" @@ -5924,7 +6008,12 @@ msgstr "フォルダを移動" msgid "Move Template to Folder" msgstr "テンプレートをフォルダに移動" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Templates to Folder" +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx msgid "Move to Folder" msgstr "フォルダに移動" @@ -6061,6 +6150,7 @@ msgid "No fields were detected in your document." msgstr "ドキュメント内でフィールドが検出されませんでした。" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "No folders found" msgstr "フォルダが見つかりません" @@ -6238,6 +6328,7 @@ msgid "On this page, you can create new Webhooks and manage the existing ones." msgstr "このページでは Webhook の新規作成と既存 Webhook の管理が行えます。" #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" msgstr "確認すると、次のことが行われます。" @@ -6612,6 +6703,10 @@ msgstr "保留中の文書" msgid "Pending Documents" msgstr "保留中の文書" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Pending documents will have their signing process cancelled" +msgstr "" + #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" msgstr "保留中の招待" @@ -6788,6 +6883,7 @@ msgid "Please note that proceeding will remove direct linking recipient and turn msgstr "続行すると、ダイレクトリンクの受信者が削除され、プレースホルダーに変換されます。" #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Please note that this action is <0>irreversible." msgstr "この操作は<0>取り消せませんのでご注意ください。" @@ -7136,7 +7232,7 @@ msgstr "受信者は文書のコピーを保持したままです" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "新しいドキュメントに自動的に追加される受信者です。" #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7602,6 +7698,7 @@ msgid "Search documents..." msgstr "文書を検索..." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -7679,6 +7776,11 @@ msgstr "タイムゾーンを選択" msgid "Select access methods" msgstr "アクセス方法を選択" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select all" +msgstr "" + #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Select an event type" msgstr "イベントタイプを選択" @@ -7758,6 +7860,11 @@ msgstr "パスキーを選択" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" +msgstr "受信者を選択" + +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select row" msgstr "" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx @@ -7806,11 +7913,23 @@ msgstr "縦位置揃えを選択" msgid "Select visibility" msgstr "公開範囲を選択" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected documents will be permanently deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Selected items have been moved." +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx msgid "Selected Recipient" msgstr "選択中の受信者" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected templates will be permanently deleted" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8812,6 +8931,14 @@ msgstr "テンプレートをアップロードしました" msgid "Templates" msgstr "テンプレート" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Test" msgstr "テスト" @@ -9009,6 +9136,10 @@ msgstr "移動しようとしているフォルダは存在しません。" msgid "The folder you are trying to move the document to does not exist." msgstr "ドキュメントの移動先として指定されたフォルダは存在しません。" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "The folder you are trying to move the items to does not exist." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "The folder you are trying to move the template to does not exist." msgstr "テンプレートの移動先として指定されたフォルダは存在しません。" @@ -10396,7 +10527,7 @@ msgstr "招待を表示" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "JSON を表示" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11103,6 +11234,10 @@ msgstr "現在、<0>{passkeyName} パスキーを更新しています。" msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "現在、チームグループ <0>{teamGroupName} を更新しています。" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "You are not allowed to move these items." +msgstr "" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "You are not allowed to move this document." msgstr "このドキュメントを移動する権限がありません。" diff --git a/packages/lib/translations/ko/web.po b/packages/lib/translations/ko/web.po index 71aa6f62d..b2168b9f1 100644 --- a/packages/lib/translations/ko/web.po +++ b/packages/lib/translations/ko/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: ko\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Korean\n" "Plural-Forms: nplurals=1; plural=0;\n" @@ -179,6 +179,16 @@ msgstr "{0, plural, other {페이지 {1}/{2} - 수신자 #명 발견됨}}" msgid "{0, plural, one {Recipient added} other {Recipients added}}" msgstr "{0, plural, other {수신자 추가됨}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" +msgstr "" + #. placeholder {0}: pendingRecipients.length #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}" @@ -194,6 +204,16 @@ msgstr "{0, plural, other {문서에서 필드 #개를 발견했습니다.}}" msgid "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" msgstr "{0, plural, other {문서에서 수신자 #명을 발견했습니다.}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" +msgstr "" + #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #. placeholder {0}: route.label #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -223,6 +243,17 @@ msgstr "{0}에서 \"{1}\" 문서에 대해 귀하께 {recipientActionVerb}하도 msgid "{0} invited you to {recipientActionVerb} a document" msgstr "{0}이(가) 귀하께 문서에 {recipientActionVerb}하도록 초대했습니다." +#. placeholder {0}: result.deletedCount +#. placeholder {1}: result.failedIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) deleted. {1} item(s) could not be deleted." +msgstr "" + +#. placeholder {0}: result.deletedCount +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) have been deleted." +msgstr "" + #. placeholder {0}: remaining.documents #. placeholder {1}: quota.documents #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -462,6 +493,10 @@ msgstr "{recipientReference}님이 {documentName}에 서명했습니다." msgid "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" msgstr "{remaningLength, plural, other {#자 남음}}" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "{selectedCount} selected" +msgstr "" + #: packages/email/template-components/template-document-rejected.tsx msgid "{signerName} has rejected the document \"{documentName}\"." msgstr "{signerName}님이 \"{documentName}\" 문서를 거부했습니다." @@ -1362,11 +1397,16 @@ msgstr "모든 폴더" msgid "All inserted signatures will be voided" msgstr "삽입된 모든 서명이 무효화됩니다" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "All items must be of the same type." +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly." msgstr "모든 수신자가 서명했습니다. 문서를 처리 중이며 곧 서명된 문서의 사본이 이메일로 전송됩니다." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "All recipients will be notified" msgstr "모든 수신자에게 알림이 전송됩니다" @@ -1528,6 +1568,10 @@ msgstr "템플릿에서 문서를 생성하는 중 오류가 발생했습니다. msgid "An error occurred while creating the webhook. Please try again." msgstr "웹훅을 생성하는 중 오류가 발생했습니다. 다시 시도해 주세요." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "An error occurred while deleting the items." +msgstr "" + #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx msgid "An error occurred while deleting the user." msgstr "사용자를 삭제하는 동안 오류가 발생했습니다." @@ -1560,6 +1604,10 @@ msgstr "문서를 불러오는 동안 오류가 발생했습니다." msgid "An error occurred while moving the document." msgstr "문서를 이동하는 중 오류가 발생했습니다." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "An error occurred while moving the items." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "An error occurred while moving the template." msgstr "템플릿을 이동하는 중 오류가 발생했습니다." @@ -1951,7 +1999,7 @@ msgstr "감사 로그" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "감사 로그 세부 정보" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -2165,6 +2213,8 @@ msgstr "준비 가능" #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -2372,6 +2422,10 @@ msgstr "클레임" msgid "Clear filters" msgstr "필터 지우기" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "Clear selection" +msgstr "" + #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Clear Signature" msgstr "서명 지우기" @@ -3115,7 +3169,7 @@ msgstr "새 사용자 기본 조직 역할" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "기본 수신인들" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -3141,6 +3195,7 @@ msgstr "delete" #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -3159,6 +3214,7 @@ msgstr "delete" #: apps/remix/app/components/general/folder/folder-card.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -3213,6 +3269,10 @@ msgstr "문서 삭제" msgid "Delete Document" msgstr "문서 삭제" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Documents" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx msgid "Delete email" msgstr "이메일 삭제" @@ -3262,6 +3322,10 @@ msgstr "팀 그룹 삭제" msgid "Delete Template" msgstr "템플릿 삭제" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Templates" +msgstr "" + #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "Delete the document. This action is irreversible so proceed with caution." msgstr "문서를 삭제합니다. 이 작업은 되돌릴 수 없으므로 신중히 진행하세요." @@ -3399,6 +3463,10 @@ msgstr "직접 링크 서명이 활성화되었습니다" msgid "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." msgstr "직접 링크 템플릿에는 하나의 동적 수신자 플레이스홀더가 포함됩니다. 링크에 접근할 수 있는 모든 사람이 문서에 서명할 수 있으며, 해당 문서는 문서 페이지에 표시됩니다." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Direct links associated with templates will be removed" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link deleted" msgstr "직접 템플릿 링크가 삭제되었습니다" @@ -3872,6 +3940,14 @@ msgstr "생성된 문서" msgid "Documents created from template" msgstr "템플릿에서 생성된 문서" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Documents Received" msgstr "수신 문서" @@ -4476,6 +4552,7 @@ msgstr "봉투가 업데이트되었습니다" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -4782,6 +4859,7 @@ msgid "Fill in the details to create a new subscription claim." msgstr "새 구독 클레임을 생성하려면 세부 정보를 입력하세요." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Folder" msgstr "폴더" @@ -5104,6 +5182,7 @@ msgid "Home" msgstr "홈" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Home (No Folder)" msgstr "홈(폴더 없음)" @@ -5902,6 +5981,7 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "월간 활성 사용자: 문서가 하나 이상 완료된 사용자" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -5916,6 +5996,10 @@ msgstr "\"{templateTitle}\"을(를) 폴더로 이동" msgid "Move Document to Folder" msgstr "문서를 폴더로 이동" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Documents to Folder" +msgstr "" + #: apps/remix/app/components/dialogs/folder-move-dialog.tsx msgid "Move Folder" msgstr "폴더 이동" @@ -5924,7 +6008,12 @@ msgstr "폴더 이동" msgid "Move Template to Folder" msgstr "템플릿을 폴더로 이동" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Templates to Folder" +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx msgid "Move to Folder" msgstr "폴더로 이동" @@ -6061,6 +6150,7 @@ msgid "No fields were detected in your document." msgstr "문서에서 감지된 필드가 없습니다." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "No folders found" msgstr "폴더를 찾을 수 없습니다." @@ -6238,6 +6328,7 @@ msgid "On this page, you can create new Webhooks and manage the existing ones." msgstr "이 페이지에서 새 웹훅을 생성하고 기존 웹훅을 관리할 수 있습니다." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" msgstr "확인되면 다음 작업이 수행됩니다:" @@ -6612,6 +6703,10 @@ msgstr "보류 중인 문서" msgid "Pending Documents" msgstr "보류 중인 문서" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Pending documents will have their signing process cancelled" +msgstr "" + #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" msgstr "보류 중인 초대장" @@ -6788,6 +6883,7 @@ msgid "Please note that proceeding will remove direct linking recipient and turn msgstr "이 작업을 계속하면 직접 링크 수신자가 제거되고 플레이스홀더로 변경됩니다." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Please note that this action is <0>irreversible." msgstr "이 작업은 <0>되돌릴 수 없습니다." @@ -7136,7 +7232,7 @@ msgstr "수신자 지표" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "새 문서에 자동으로 추가될 수신인들입니다." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7602,6 +7698,7 @@ msgid "Search documents..." msgstr "문서 검색..." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -7679,6 +7776,11 @@ msgstr "시간대를 선택하세요." msgid "Select access methods" msgstr "접근 방식 선택" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select all" +msgstr "" + #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Select an event type" msgstr "이벤트 유형을 선택하세요" @@ -7758,6 +7860,11 @@ msgstr "패스키 선택" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" +msgstr "수신인 선택" + +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select row" msgstr "" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx @@ -7806,11 +7913,23 @@ msgstr "세로 정렬 선택" msgid "Select visibility" msgstr "공개 범위 선택" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected documents will be permanently deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Selected items have been moved." +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx msgid "Selected Recipient" msgstr "선택된 수신자" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected templates will be permanently deleted" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8812,6 +8931,14 @@ msgstr "템플릿이 업로드되었습니다." msgid "Templates" msgstr "템플릿" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Test" msgstr "테스트" @@ -9009,6 +9136,10 @@ msgstr "이동하려는 폴더가 존재하지 않습니다." msgid "The folder you are trying to move the document to does not exist." msgstr "문서를 이동하려는 폴더가 존재하지 않습니다." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "The folder you are trying to move the items to does not exist." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "The folder you are trying to move the template to does not exist." msgstr "템플릿을 이동하려는 폴더가 존재하지 않습니다." @@ -10396,7 +10527,7 @@ msgstr "초대 보기" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "JSON 보기" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11103,6 +11234,10 @@ msgstr "현재 <0>{passkeyName} 패스키를 업데이트하는 중입니다 msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "현재 <0>{teamGroupName} 팀 그룹을 업데이트하고 있습니다." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "You are not allowed to move these items." +msgstr "" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "You are not allowed to move this document." msgstr "이 문서를 이동할 권한이 없습니다." diff --git a/packages/lib/translations/nl/web.po b/packages/lib/translations/nl/web.po index 23dfb42b0..46fc85cd4 100644 --- a/packages/lib/translations/nl/web.po +++ b/packages/lib/translations/nl/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: nl\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Dutch\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -179,6 +179,16 @@ msgstr "{0, plural, one {Pagina {1} van {2} - # ontvanger gevonden} other {Pagin msgid "{0, plural, one {Recipient added} other {Recipients added}}" msgstr "{0, plural, one {Ontvanger toegevoegd} other {Ontvangers toegevoegd}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" +msgstr "" + #. placeholder {0}: pendingRecipients.length #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}" @@ -194,6 +204,16 @@ msgstr "{0, plural, one {We hebben # veld in je document gevonden.} other {We he msgid "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" msgstr "{0, plural, one {We hebben # ontvanger in je document gevonden.} other {We hebben # ontvangers in je document gevonden.}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" +msgstr "" + #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #. placeholder {0}: route.label #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -223,6 +243,17 @@ msgstr "{0} heeft je uitgenodigd om het document \"{1}\" te {recipientActionVerb msgid "{0} invited you to {recipientActionVerb} a document" msgstr "{0} heeft je uitgenodigd om een document te {recipientActionVerb}" +#. placeholder {0}: result.deletedCount +#. placeholder {1}: result.failedIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) deleted. {1} item(s) could not be deleted." +msgstr "" + +#. placeholder {0}: result.deletedCount +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) have been deleted." +msgstr "" + #. placeholder {0}: remaining.documents #. placeholder {1}: quota.documents #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -462,6 +493,10 @@ msgstr "{recipientReference} heeft {documentName} ondertekend" msgid "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" msgstr "{remaningLength, plural, one {# teken resterend} other {# tekens resterend}}" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "{selectedCount} selected" +msgstr "" + #: packages/email/template-components/template-document-rejected.tsx msgid "{signerName} has rejected the document \"{documentName}\"." msgstr "{signerName} heeft het document \"{documentName}\" geweigerd." @@ -1362,11 +1397,16 @@ msgstr "Alle mappen" msgid "All inserted signatures will be voided" msgstr "Alle ingevoerde handtekeningen worden ongeldig gemaakt" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "All items must be of the same type." +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly." msgstr "Alle ontvangers hebben ondertekend. Het document wordt verwerkt en u ontvangt binnenkort een kopie per e-mail." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "All recipients will be notified" msgstr "Alle ontvangers worden op de hoogte gebracht" @@ -1528,6 +1568,10 @@ msgstr "Er is een fout opgetreden bij het aanmaken van een document op basis van msgid "An error occurred while creating the webhook. Please try again." msgstr "Er is een fout opgetreden bij het aanmaken van de webhook. Probeer het opnieuw." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "An error occurred while deleting the items." +msgstr "" + #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx msgid "An error occurred while deleting the user." msgstr "Er is een fout opgetreden tijdens het verwijderen van de gebruiker." @@ -1560,6 +1604,10 @@ msgstr "Er is een fout opgetreden tijdens het laden van het document." msgid "An error occurred while moving the document." msgstr "Er is een fout opgetreden bij het verplaatsen van het document." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "An error occurred while moving the items." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "An error occurred while moving the template." msgstr "Er is een fout opgetreden bij het verplaatsen van de sjabloon." @@ -1951,7 +1999,7 @@ msgstr "Auditlog" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Details van auditlogboek" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -2165,6 +2213,8 @@ msgstr "Kan voorbereiden" #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -2372,6 +2422,10 @@ msgstr "Claims" msgid "Clear filters" msgstr "Filters wissen" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "Clear selection" +msgstr "" + #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Clear Signature" msgstr "Handtekening wissen" @@ -3115,7 +3169,7 @@ msgstr "Standaard organisatierol voor nieuwe gebruikers" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Standaardontvangers" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -3141,6 +3195,7 @@ msgstr "verwijderen" #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -3159,6 +3214,7 @@ msgstr "verwijderen" #: apps/remix/app/components/general/folder/folder-card.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -3213,6 +3269,10 @@ msgstr "Document verwijderen" msgid "Delete Document" msgstr "Document verwijderen" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Documents" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx msgid "Delete email" msgstr "E-mail verwijderen" @@ -3262,6 +3322,10 @@ msgstr "Teamgroep verwijderen" msgid "Delete Template" msgstr "Sjabloon verwijderen" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Templates" +msgstr "" + #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "Delete the document. This action is irreversible so proceed with caution." msgstr "Verwijder het document. Deze actie kan niet ongedaan worden gemaakt, ga dus voorzichtig te werk." @@ -3399,6 +3463,10 @@ msgstr "Ondertekenen via directe link is ingeschakeld" msgid "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." msgstr "Sjablonen met directe link bevatten één dynamische ontvangers‑placeholder. Iedereen met toegang tot deze link kan het document ondertekenen; het verschijnt daarna op je documentpagina." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Direct links associated with templates will be removed" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link deleted" msgstr "Directe sjabloonlink verwijderd" @@ -3872,6 +3940,14 @@ msgstr "Documenten aangemaakt" msgid "Documents created from template" msgstr "Documenten aangemaakt vanuit sjabloon" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Documents Received" msgstr "Ontvangen documenten" @@ -4476,6 +4552,7 @@ msgstr "Envelope bijgewerkt" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -4782,6 +4859,7 @@ msgid "Fill in the details to create a new subscription claim." msgstr "Vul de gegevens in om een nieuwe abonnementsclaim aan te maken." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Folder" msgstr "Map" @@ -5104,6 +5182,7 @@ msgid "Home" msgstr "Home" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Home (No Folder)" msgstr "Home (geen map)" @@ -5902,6 +5981,7 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Maandelijks actieve gebruikers: gebruikers van wie ten minste één document is voltooid" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -5916,6 +5996,10 @@ msgstr "\"{templateTitle}\" naar een map verplaatsen" msgid "Move Document to Folder" msgstr "Document naar map verplaatsen" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Documents to Folder" +msgstr "" + #: apps/remix/app/components/dialogs/folder-move-dialog.tsx msgid "Move Folder" msgstr "Map verplaatsen" @@ -5924,7 +6008,12 @@ msgstr "Map verplaatsen" msgid "Move Template to Folder" msgstr "Sjabloon naar map verplaatsen" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Templates to Folder" +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx msgid "Move to Folder" msgstr "Naar map verplaatsen" @@ -6061,6 +6150,7 @@ msgid "No fields were detected in your document." msgstr "Er zijn geen velden in uw document gedetecteerd." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "No folders found" msgstr "Geen mappen gevonden" @@ -6238,6 +6328,7 @@ msgid "On this page, you can create new Webhooks and manage the existing ones." msgstr "Op deze pagina kun je nieuwe webhooks aanmaken en bestaande beheren." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" msgstr "Na bevestiging gebeurt het volgende:" @@ -6612,6 +6703,10 @@ msgstr "Documenten in behandeling" msgid "Pending Documents" msgstr "Documenten in behandeling" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Pending documents will have their signing process cancelled" +msgstr "" + #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" msgstr "Openstaande uitnodigingen" @@ -6788,6 +6883,7 @@ msgid "Please note that proceeding will remove direct linking recipient and turn msgstr "Let op: doorgaan verwijdert de ontvanger voor direct linken en verandert deze in een placeholder." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Please note that this action is <0>irreversible." msgstr "Let op: deze actie is <0>onomkeerbaar." @@ -7136,7 +7232,7 @@ msgstr "Ontvangerstatistieken" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Ontvangers die automatisch aan nieuwe documenten worden toegevoegd." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7602,6 +7698,7 @@ msgid "Search documents..." msgstr "Documenten zoeken..." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -7679,6 +7776,11 @@ msgstr "Selecteer een tijdzone" msgid "Select access methods" msgstr "Selecteer toegangs­methoden" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select all" +msgstr "" + #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Select an event type" msgstr "Selecteer een gebeurtenistype" @@ -7758,6 +7860,11 @@ msgstr "Passkey selecteren" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" +msgstr "Ontvangers selecteren" + +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select row" msgstr "" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx @@ -7806,11 +7913,23 @@ msgstr "Selecteer verticale uitlijning" msgid "Select visibility" msgstr "Selecteer zichtbaarheid" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected documents will be permanently deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Selected items have been moved." +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx msgid "Selected Recipient" msgstr "Geselecteerde ontvanger" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected templates will be permanently deleted" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8812,6 +8931,14 @@ msgstr "Sjabloon geüpload" msgid "Templates" msgstr "Sjablonen" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Test" msgstr "Test" @@ -9009,6 +9136,10 @@ msgstr "De map die je probeert te verplaatsen, bestaat niet." msgid "The folder you are trying to move the document to does not exist." msgstr "De map waarnaar je het document probeert te verplaatsen, bestaat niet." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "The folder you are trying to move the items to does not exist." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "The folder you are trying to move the template to does not exist." msgstr "De map waarnaar je de sjabloon probeert te verplaatsen, bestaat niet." @@ -10396,7 +10527,7 @@ msgstr "Uitnodigingen bekijken" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "JSON bekijken" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11103,6 +11234,10 @@ msgstr "Je bent momenteel de passkey <0>{passkeyName} aan het bijwerken." msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "Je werkt momenteel de teamgroep <0>{teamGroupName} bij." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "You are not allowed to move these items." +msgstr "" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "You are not allowed to move this document." msgstr "Je mag dit document niet verplaatsen." diff --git a/packages/lib/translations/pl/web.po b/packages/lib/translations/pl/web.po index f5fb94836..e066f9a3f 100644 --- a/packages/lib/translations/pl/web.po +++ b/packages/lib/translations/pl/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: pl\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-15 04:18\n" "Last-Translator: \n" "Language-Team: Polish\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" @@ -179,6 +179,16 @@ msgstr "{0, plural, one {Strona {1} z {2} - znaleziono # odbiorcę} few {Strona msgid "{0, plural, one {Recipient added} other {Recipients added}}" msgstr "{0, plural, one {Odbiorca został dodany} few {Odbiorcy zostali dodani} many {Odbiorcy zostali dodani} other {Odbiorcy zostali dodani}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" +msgstr "" + #. placeholder {0}: pendingRecipients.length #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}" @@ -194,6 +204,16 @@ msgstr "{0, plural, one {Znaleźliśmy # pole w dokumencie.} few {Znaleźliśmy msgid "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" msgstr "{0, plural, one {Znaleźliśmy # odbiorcę w dokumencie.} few {Znaleźliśmy # odbiorców w dokumencie.} many {Znaleźliśmy # odbiorców w dokumencie.} other {Znaleźliśmy # odbiorców w dokumencie.}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" +msgstr "" + #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #. placeholder {0}: route.label #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -223,6 +243,17 @@ msgstr "Sprawdź i {recipientActionVerb} dokument „{1}” utworzony przez zesp msgid "{0} invited you to {recipientActionVerb} a document" msgstr "Sprawdź i {recipientActionVerb} dokument utworzony przez zespół {0}" +#. placeholder {0}: result.deletedCount +#. placeholder {1}: result.failedIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) deleted. {1} item(s) could not be deleted." +msgstr "" + +#. placeholder {0}: result.deletedCount +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) have been deleted." +msgstr "" + #. placeholder {0}: remaining.documents #. placeholder {1}: quota.documents #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -462,6 +493,10 @@ msgstr "Użytkownik {recipientReference} podpisał dokument „{documentName}” msgid "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" msgstr "{remaningLength, plural, one {Pozostał # znak} few {Pozostały # znaki} many {Pozostało # znaków} other {Pozostało # znaków}}" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "{selectedCount} selected" +msgstr "" + #: packages/email/template-components/template-document-rejected.tsx msgid "{signerName} has rejected the document \"{documentName}\"." msgstr "Użytkownik {signerName} odrzucił dokument „{documentName}”." @@ -1362,11 +1397,16 @@ msgstr "Wszystkie foldery" msgid "All inserted signatures will be voided" msgstr "Wszystkie wstawione podpisy zostaną unieważnione" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "All items must be of the same type." +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly." msgstr "Wszyscy odbiorcy podpisali dokument. Dokument jest przetwarzany i wkrótce otrzymasz jego kopię." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "All recipients will be notified" msgstr "Wszyscy odbiorcy zostaną powiadomieni" @@ -1528,6 +1568,10 @@ msgstr "Wystąpił błąd podczas tworzenia dokumentu z szablonu." msgid "An error occurred while creating the webhook. Please try again." msgstr "Wystąpił błąd podczas tworzenia webhooka. Spróbuj ponownie." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "An error occurred while deleting the items." +msgstr "" + #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx msgid "An error occurred while deleting the user." msgstr "Wystąpił błąd podczas usuwania użytkownika." @@ -1560,6 +1604,10 @@ msgstr "Wystąpił błąd podczas ładowania dokumentu." msgid "An error occurred while moving the document." msgstr "Wystąpił błąd podczas przenoszenia dokumentu." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "An error occurred while moving the items." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "An error occurred while moving the template." msgstr "Wystąpił błąd podczas przenoszenia szablonu." @@ -1951,7 +1999,7 @@ msgstr "Dziennik logów" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Szczegóły dziennika logu" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -2165,6 +2213,8 @@ msgstr "Może przygotować" #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -2372,6 +2422,10 @@ msgstr "Subskrypcje" msgid "Clear filters" msgstr "Wyczyść filtry" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "Clear selection" +msgstr "" + #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Clear Signature" msgstr "Wyczyść podpis" @@ -3115,7 +3169,7 @@ msgstr "Domyślna rola w organizacji dla nowych użytkowników" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Domyślni odbiorcy" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -3141,6 +3195,7 @@ msgstr "usuń" #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -3159,6 +3214,7 @@ msgstr "usuń" #: apps/remix/app/components/general/folder/folder-card.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -3213,6 +3269,10 @@ msgstr "Usuń dokument" msgid "Delete Document" msgstr "Usuń dokument" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Documents" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx msgid "Delete email" msgstr "Usuń adres e-mail" @@ -3262,6 +3322,10 @@ msgstr "Usuń grupę zespołu" msgid "Delete Template" msgstr "Usuń szablon" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Templates" +msgstr "" + #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "Delete the document. This action is irreversible so proceed with caution." msgstr "Usuń dokument. Ta akcja jest nieodwracalna." @@ -3399,6 +3463,10 @@ msgstr "Podpisywanie za pomocą bezpośredniego linku zostało włączone" msgid "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." msgstr "Szablony bezpośrednich linków zawierają jeden domyślny tekst odbiorcy. Każdy, kto ma dostęp do tego linku, może podpisać dokument." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Direct links associated with templates will be removed" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link deleted" msgstr "Bezpośredni link do szablonu został usunięty" @@ -3872,6 +3940,14 @@ msgstr "Utworzone dokumenty" msgid "Documents created from template" msgstr "Dokumenty utworzone z szablonu" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Documents Received" msgstr "Odebrane dokumenty" @@ -4343,7 +4419,7 @@ msgstr "Załączniki" #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx msgid "Enter" -msgstr "Wpisz lub wprowadź wartość" +msgstr "Wpisz" #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." @@ -4363,7 +4439,7 @@ msgstr "Wpisz inicjały" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx msgid "Enter Name" -msgstr "Wpisz imię i nazwisko" +msgstr "Wpisz nazwę" #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Enter Number" @@ -4476,6 +4552,7 @@ msgstr "Koperta została zaktualizowana" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -4782,6 +4859,7 @@ msgid "Fill in the details to create a new subscription claim." msgstr "Uzupełnił szczegóły, aby utworzyć nową subskrypcję." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Folder" msgstr "Folder" @@ -5104,6 +5182,7 @@ msgid "Home" msgstr "Strona główna" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Home (No Folder)" msgstr "Strona główna (brak folderu)" @@ -5902,6 +5981,7 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Miesięczna liczba aktywnych użytkowników: Użytkownicy, którzy zakończyli co najmniej jeden dokument" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -5916,6 +5996,10 @@ msgstr "Przenieś szablon „{templateTitle}” do folderu" msgid "Move Document to Folder" msgstr "Przenieś dokument do folderu" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Documents to Folder" +msgstr "" + #: apps/remix/app/components/dialogs/folder-move-dialog.tsx msgid "Move Folder" msgstr "Przenieś folder" @@ -5924,7 +6008,12 @@ msgstr "Przenieś folder" msgid "Move Template to Folder" msgstr "Przenieś szablon do folderu" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Templates to Folder" +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx msgid "Move to Folder" msgstr "Przenieś do folderu" @@ -6061,6 +6150,7 @@ msgid "No fields were detected in your document." msgstr "Nie wykryto żadnych pól." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "No folders found" msgstr "Nie znaleziono folderów" @@ -6238,6 +6328,7 @@ msgid "On this page, you can create new Webhooks and manage the existing ones." msgstr "Na tej stronie możesz utworzyć nowe webhooki i zarządzać obecnymi." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" msgstr "Potwierdzenie akcji spowoduje następujące skutki:" @@ -6612,6 +6703,10 @@ msgstr "Oczekujące dokumenty" msgid "Pending Documents" msgstr "Oczekujące dokumenty" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Pending documents will have their signing process cancelled" +msgstr "" + #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" msgstr "Oczekujące zaproszenia" @@ -6761,15 +6856,15 @@ msgstr "Wpisz wartość" #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx msgid "Please enter your email address" -msgstr "Wpisz swój adres e-mail" +msgstr "Wpisz adres e-mail" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx msgid "Please enter your full name" -msgstr "Wpisz swoje pełne imię i nazwisko" +msgstr "Wpisz nazwę" #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx msgid "Please enter your initials" -msgstr "Wprowadź swoje inicjały" +msgstr "Wpisz inicjały" #: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx msgid "Please mark as viewed to complete" @@ -6788,6 +6883,7 @@ msgid "Please note that proceeding will remove direct linking recipient and turn msgstr "Spowoduje to usunięcie odbiorcy bezpośredniego linku na domyślny tekst." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Please note that this action is <0>irreversible." msgstr "Ta akcja jest <0>nieodwracalna." @@ -7136,7 +7232,7 @@ msgstr "Metryki odbiorców" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Odbiorcy, którzy będą automatycznie dodawani do nowych dokumentów." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7602,6 +7698,7 @@ msgid "Search documents..." msgstr "Szukaj dokumentów..." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -7679,6 +7776,11 @@ msgstr "Wybierz strefę czasową" msgid "Select access methods" msgstr "Wybierz metody dostępu" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select all" +msgstr "" + #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Select an event type" msgstr "Wybierz rodzaj zdarzenia" @@ -7758,6 +7860,11 @@ msgstr "Wybierz klucz dostępu" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" +msgstr "Wybierz odbiorców" + +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select row" msgstr "" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx @@ -7806,11 +7913,23 @@ msgstr "Wybierz wyrównanie w pionie" msgid "Select visibility" msgstr "Wybierz widoczność" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected documents will be permanently deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Selected items have been moved." +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx msgid "Selected Recipient" msgstr "Wybrany odbiorca" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected templates will be permanently deleted" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8812,6 +8931,14 @@ msgstr "Szablon został przesłany" msgid "Templates" msgstr "Szablony" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Test" msgstr "Testuj" @@ -9009,6 +9136,10 @@ msgstr "Folder, który próbujesz przenieść, nie istnieje." msgid "The folder you are trying to move the document to does not exist." msgstr "Folder, do którego próbujesz przenieść dokument, nie istnieje." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "The folder you are trying to move the items to does not exist." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "The folder you are trying to move the template to does not exist." msgstr "Folder, do którego próbujesz przenieść szablon, nie istnieje." @@ -10396,7 +10527,7 @@ msgstr "Wyświetl zaproszenia" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "Zobacz JSON" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11103,6 +11234,10 @@ msgstr "Aktualizujesz klucz dostępu <0>{passkeyName}." msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "Aktualizujesz grupę <0>{teamGroupName}." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "You are not allowed to move these items." +msgstr "" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "You are not allowed to move this document." msgstr "Nie masz uprawnień do przeniesienia tego dokumentu." diff --git a/packages/lib/translations/pt-BR/web.po b/packages/lib/translations/pt-BR/web.po index 21d7aef63..ccc34db46 100644 --- a/packages/lib/translations/pt-BR/web.po +++ b/packages/lib/translations/pt-BR/web.po @@ -174,6 +174,16 @@ msgstr "" msgid "{0, plural, one {Recipient added} other {Recipients added}}" msgstr "" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" +msgstr "" + #. placeholder {0}: pendingRecipients.length #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}" @@ -189,6 +199,16 @@ msgstr "" msgid "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" msgstr "" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" +msgstr "" + #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #. placeholder {0}: route.label #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -218,6 +238,17 @@ msgstr "{0} convidou você para {recipientActionVerb} o documento \"{1}\"." msgid "{0} invited you to {recipientActionVerb} a document" msgstr "{0} convidou você para {recipientActionVerb} um documento" +#. placeholder {0}: result.deletedCount +#. placeholder {1}: result.failedIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) deleted. {1} item(s) could not be deleted." +msgstr "" + +#. placeholder {0}: result.deletedCount +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) have been deleted." +msgstr "" + #. placeholder {0}: remaining.documents #. placeholder {1}: quota.documents #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -457,6 +488,10 @@ msgstr "{recipientReference} assinou {documentName}" msgid "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" msgstr "{remaningLength, plural, one {# caractere restante} other {# caracteres restantes}}" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "{selectedCount} selected" +msgstr "" + #: packages/email/template-components/template-document-rejected.tsx msgid "{signerName} has rejected the document \"{documentName}\"." msgstr "{signerName} rejeitou o documento \"{documentName}\"." @@ -1357,11 +1392,16 @@ msgstr "Todas as Pastas" msgid "All inserted signatures will be voided" msgstr "Todas as assinaturas inseridas serão anuladas" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "All items must be of the same type." +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly." msgstr "Todos os destinatários assinaram. O documento está sendo processado e você receberá uma cópia por e-mail em breve." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "All recipients will be notified" msgstr "Todos os destinatários serão notificados" @@ -1523,6 +1563,10 @@ msgstr "Ocorreu um erro ao criar o documento a partir do modelo." msgid "An error occurred while creating the webhook. Please try again." msgstr "Ocorreu um erro ao criar o webhook. Por favor, tente novamente." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "An error occurred while deleting the items." +msgstr "" + #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx msgid "An error occurred while deleting the user." msgstr "Ocorreu um erro ao excluir o usuário." @@ -1555,6 +1599,10 @@ msgstr "Ocorreu um erro ao carregar o documento." msgid "An error occurred while moving the document." msgstr "Ocorreu um erro ao mover o documento." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "An error occurred while moving the items." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "An error occurred while moving the template." msgstr "Ocorreu um erro ao mover o modelo." @@ -2160,6 +2208,8 @@ msgstr "Pode preparar" #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -2367,6 +2417,10 @@ msgstr "Reivindicações" msgid "Clear filters" msgstr "Limpar filtros" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "Clear selection" +msgstr "" + #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Clear Signature" msgstr "Limpar Assinatura" @@ -3136,6 +3190,7 @@ msgstr "excluir" #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -3154,6 +3209,7 @@ msgstr "excluir" #: apps/remix/app/components/general/folder/folder-card.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -3208,6 +3264,10 @@ msgstr "Excluir documento" msgid "Delete Document" msgstr "Excluir Documento" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Documents" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx msgid "Delete email" msgstr "Excluir e-mail" @@ -3257,6 +3317,10 @@ msgstr "Excluir grupo da equipe" msgid "Delete Template" msgstr "Excluir Modelo" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Templates" +msgstr "" + #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "Delete the document. This action is irreversible so proceed with caution." msgstr "Excluir o documento. Esta ação é irreversível, portanto, prossiga com cautela." @@ -3394,6 +3458,10 @@ msgstr "A assinatura por link direto foi ativada" msgid "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." msgstr "Modelos de link direto contêm um espaço reservado para destinatário dinâmico. Qualquer pessoa com acesso a este link pode assinar o documento, e ele aparecerá na sua página de documentos." +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Direct links associated with templates will be removed" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link deleted" msgstr "Link de modelo direto excluído" @@ -3867,6 +3935,14 @@ msgstr "Documentos Criados" msgid "Documents created from template" msgstr "Documentos criados a partir do modelo" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Documents Received" msgstr "Documentos Recebidos" @@ -4471,6 +4547,7 @@ msgstr "Envelope atualizado" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -4777,6 +4854,7 @@ msgid "Fill in the details to create a new subscription claim." msgstr "Preencha os detalhes para criar uma nova reivindicação de assinatura." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Folder" msgstr "Pasta" @@ -5099,6 +5177,7 @@ msgid "Home" msgstr "Início" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Home (No Folder)" msgstr "Início (Sem Pasta)" @@ -5897,6 +5976,7 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Usuários Ativos Mensais: Usuários que tiveram pelo menos um de seus documentos concluídos" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -5911,6 +5991,10 @@ msgstr "Mover \"{templateTitle}\" para uma pasta" msgid "Move Document to Folder" msgstr "Mover Documento para Pasta" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Documents to Folder" +msgstr "" + #: apps/remix/app/components/dialogs/folder-move-dialog.tsx msgid "Move Folder" msgstr "Mover Pasta" @@ -5919,7 +6003,12 @@ msgstr "Mover Pasta" msgid "Move Template to Folder" msgstr "Mover Modelo para Pasta" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Templates to Folder" +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx msgid "Move to Folder" msgstr "Mover para Pasta" @@ -6056,6 +6145,7 @@ msgid "No fields were detected in your document." msgstr "Nenhum campo foi detectado em seu documento." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "No folders found" msgstr "Nenhuma pasta encontrada" @@ -6233,6 +6323,7 @@ msgid "On this page, you can create new Webhooks and manage the existing ones." msgstr "Nesta página, você pode criar novos Webhooks e gerenciar os existentes." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" msgstr "Uma vez confirmado, o seguinte ocorrerá:" @@ -6607,6 +6698,10 @@ msgstr "Documentos pendentes" msgid "Pending Documents" msgstr "Documentos Pendentes" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Pending documents will have their signing process cancelled" +msgstr "" + #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" msgstr "Convites pendentes" @@ -6783,6 +6878,7 @@ msgid "Please note that proceeding will remove direct linking recipient and turn msgstr "Observe que prosseguir removerá o destinatário do link direto e o transformará em um espaço reservado." #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Please note that this action is <0>irreversible." msgstr "Observe que esta ação é <0>irreversível." @@ -7597,6 +7693,7 @@ msgid "Search documents..." msgstr "Pesquisar documentos..." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -7674,6 +7771,11 @@ msgstr "Selecione um fuso horário" msgid "Select access methods" msgstr "Selecione métodos de acesso" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select all" +msgstr "" + #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Select an event type" msgstr "Selecione um tipo de evento" @@ -7755,6 +7857,11 @@ msgstr "Selecionar passkey" msgid "Select recipients" msgstr "" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select row" +msgstr "" + #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: packages/ui/primitives/document-flow/add-settings.tsx @@ -7801,11 +7908,23 @@ msgstr "Selecione o alinhamento vertical" msgid "Select visibility" msgstr "Selecione a visibilidade" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected documents will be permanently deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Selected items have been moved." +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx msgid "Selected Recipient" msgstr "Destinatário Selecionado" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected templates will be permanently deleted" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8807,6 +8926,14 @@ msgstr "Modelo enviado" msgid "Templates" msgstr "Modelos" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Test" msgstr "Teste" @@ -9004,6 +9131,10 @@ msgstr "A pasta que você está tentando mover não existe." msgid "The folder you are trying to move the document to does not exist." msgstr "A pasta para a qual você está tentando mover o documento não existe." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "The folder you are trying to move the items to does not exist." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "The folder you are trying to move the template to does not exist." msgstr "A pasta para a qual você está tentando mover o modelo não existe." @@ -11098,6 +11229,10 @@ msgstr "Você está atualizando a passkey <0>{passkeyName}." msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "Você está atualizando o grupo de equipe <0>{teamGroupName}." +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "You are not allowed to move these items." +msgstr "" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "You are not allowed to move this document." msgstr "Você não tem permissão para mover este documento." diff --git a/packages/lib/translations/zh/web.po b/packages/lib/translations/zh/web.po index 39a82f678..43334bc5e 100644 --- a/packages/lib/translations/zh/web.po +++ b/packages/lib/translations/zh/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: zh\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Chinese Simplified\n" "Plural-Forms: nplurals=1; plural=0;\n" @@ -179,6 +179,16 @@ msgstr "{0, plural, other {第 {1} 页,共 {2} 页 - 找到 # 位收件人}}" msgid "{0, plural, one {Recipient added} other {Recipients added}}" msgstr "{0, plural, other {已添加收件人}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected document to.} other {Select a folder to move the # selected documents to.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "{0, plural, one {Select a folder to move the selected template to.} other {Select a folder to move the # selected templates to.}}" +msgstr "" + #. placeholder {0}: pendingRecipients.length #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "{0, plural, one {Waiting on 1 recipient} other {Waiting on # recipients}}" @@ -194,6 +204,16 @@ msgstr "{0, plural, other {我们在您的文档中找到了 # 个字段。}}" msgid "{0, plural, one {We found # recipient in your document.} other {We found # recipients in your document.}}" msgstr "{0, plural, other {我们在您的文档中找到了 # 位收件人。}}" +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected document.} other {You are about to delete # documents.}}" +msgstr "" + +#. placeholder {0}: envelopeIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0, plural, one {You are about to delete the selected template.} other {You are about to delete # templates.}}" +msgstr "" + #. placeholder {0}: _(FRIENDLY_FIELD_TYPE[fieldType as FieldType]) #. placeholder {0}: route.label #: apps/remix/app/components/general/document-signing/document-signing-auto-sign.tsx @@ -223,6 +243,17 @@ msgstr "{0} 已邀请您 {recipientActionVerb} 文档“{1}”。" msgid "{0} invited you to {recipientActionVerb} a document" msgstr "{0} 邀请您 {recipientActionVerb} 一个文档" +#. placeholder {0}: result.deletedCount +#. placeholder {1}: result.failedIds.length +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) deleted. {1} item(s) could not be deleted." +msgstr "" + +#. placeholder {0}: result.deletedCount +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "{0} item(s) have been deleted." +msgstr "" + #. placeholder {0}: remaining.documents #. placeholder {1}: quota.documents #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx @@ -462,6 +493,10 @@ msgstr "{recipientReference} 已签署 {documentName}" msgid "{remaningLength, plural, one {# character remaining} other {# characters remaining}}" msgstr "{remaningLength, plural, other {# 个字符剩余}}" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "{selectedCount} selected" +msgstr "" + #: packages/email/template-components/template-document-rejected.tsx msgid "{signerName} has rejected the document \"{documentName}\"." msgstr "{signerName} 已拒签文档“{documentName}”。" @@ -1362,11 +1397,16 @@ msgstr "所有文件夹" msgid "All inserted signatures will be voided" msgstr "所有已插入的签名将被作废" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "All items must be of the same type." +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "All recipients have signed. The document is being processed and you will receive an email copy shortly." msgstr "所有收件人都已签署。文档正在处理中,您很快会收到一份通过电子邮件发送的副本。" #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "All recipients will be notified" msgstr "所有收件人都会收到通知" @@ -1528,6 +1568,10 @@ msgstr "从模板创建文档时发生错误。" msgid "An error occurred while creating the webhook. Please try again." msgstr "创建 Webhook 时发生错误。请重试。" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "An error occurred while deleting the items." +msgstr "" + #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx msgid "An error occurred while deleting the user." msgstr "删除用户时发生错误。" @@ -1560,6 +1604,10 @@ msgstr "加载文档时发生错误。" msgid "An error occurred while moving the document." msgstr "移动文档时发生错误。" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "An error occurred while moving the items." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "An error occurred while moving the template." msgstr "移动模板时发生错误。" @@ -1951,7 +1999,7 @@ msgstr "审计日志" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "审计日志详情" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -2165,6 +2213,8 @@ msgstr "可预填" #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -2372,6 +2422,10 @@ msgstr "声明" msgid "Clear filters" msgstr "清除筛选条件" +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx +msgid "Clear selection" +msgstr "" + #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Clear Signature" msgstr "清除签名" @@ -3115,7 +3169,7 @@ msgstr "新用户的默认组织角色" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "默认收件人" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -3141,6 +3195,7 @@ msgstr "delete" #: apps/remix/app/components/dialogs/claim-delete-dialog.tsx #: apps/remix/app/components/dialogs/document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -3159,6 +3214,7 @@ msgstr "delete" #: apps/remix/app/components/general/folder/folder-card.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -3213,6 +3269,10 @@ msgstr "删除文档" msgid "Delete Document" msgstr "删除文档" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Documents" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-email-delete-dialog.tsx msgid "Delete email" msgstr "删除邮箱" @@ -3262,6 +3322,10 @@ msgstr "删除团队组" msgid "Delete Template" msgstr "删除模板" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Delete Templates" +msgstr "" + #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "Delete the document. This action is irreversible so proceed with caution." msgstr "删除该文档。此操作不可恢复,请谨慎进行。" @@ -3399,6 +3463,10 @@ msgstr "直接链接签署已被启用" msgid "Direct link templates contain one dynamic recipient placeholder. Anyone with access to this link can sign the document, and it will then appear on your documents page." msgstr "直接链接模板包含一个动态收件人占位符。任何获得此链接的人都可以签署文档,签署后文档将显示在你的文档页面中。" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Direct links associated with templates will be removed" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link deleted" msgstr "直接模板链接已删除" @@ -3872,6 +3940,14 @@ msgstr "已创建的文档" msgid "Documents created from template" msgstr "由模板创建的文档" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Documents partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Documents Received" msgstr "已接收的文档" @@ -4476,6 +4552,7 @@ msgstr "信封已更新" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx #: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx @@ -4782,6 +4859,7 @@ msgid "Fill in the details to create a new subscription claim." msgstr "填写详细信息以创建新的订阅声明。" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Folder" msgstr "文件夹" @@ -5104,6 +5182,7 @@ msgid "Home" msgstr "首页" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Home (No Folder)" msgstr "首页(无文件夹)" @@ -5902,6 +5981,7 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "月活跃用户:至少有一份文档被完成的用户" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/components/general/folder/folder-card.tsx @@ -5916,6 +5996,10 @@ msgstr "将“{templateTitle}”移动到文件夹" msgid "Move Document to Folder" msgstr "将文档移动到文件夹" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Documents to Folder" +msgstr "" + #: apps/remix/app/components/dialogs/folder-move-dialog.tsx msgid "Move Folder" msgstr "移动文件夹" @@ -5924,7 +6008,12 @@ msgstr "移动文件夹" msgid "Move Template to Folder" msgstr "将模板移动到文件夹" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Move Templates to Folder" +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx +#: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx msgid "Move to Folder" msgstr "移动到文件夹" @@ -6061,6 +6150,7 @@ msgid "No fields were detected in your document." msgstr "在您的文档中未检测到任何字段。" #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "No folders found" msgstr "未找到文件夹" @@ -6238,6 +6328,7 @@ msgid "On this page, you can create new Webhooks and manage the existing ones." msgstr "在此页面,你可以创建新的 Webhook 并管理现有的 Webhook。" #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Once confirmed, the following will occur:" msgstr "一旦确认,将会发生以下情况:" @@ -6612,6 +6703,10 @@ msgstr "待处理文档" msgid "Pending Documents" msgstr "待处理文档数量" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Pending documents will have their signing process cancelled" +msgstr "" + #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" msgstr "待处理邀请" @@ -6788,6 +6883,7 @@ msgid "Please note that proceeding will remove direct linking recipient and turn msgstr "请注意,继续操作将移除直接链接收件人,并将其转为占位符。" #: apps/remix/app/components/dialogs/document-delete-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Please note that this action is <0>irreversible." msgstr "请注意,此操作<0>不可恢复。" @@ -7136,7 +7232,7 @@ msgstr "收件人仍将保留其文档副本" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "将自动添加到新文档中的收件人。" #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7602,6 +7698,7 @@ msgid "Search documents..." msgstr "搜索文档..." #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.folders._index.tsx @@ -7679,6 +7776,11 @@ msgstr "选择时区" msgid "Select access methods" msgstr "选择访问方式" +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select all" +msgstr "" + #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Select an event type" msgstr "选择一个事件类型" @@ -7758,6 +7860,11 @@ msgstr "选择通行密钥" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" +msgstr "选择收件人" + +#: apps/remix/app/components/tables/documents-table.tsx +#: apps/remix/app/components/tables/templates-table.tsx +msgid "Select row" msgstr "" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx @@ -7806,11 +7913,23 @@ msgstr "选择垂直对齐方式" msgid "Select visibility" msgstr "选择可见性" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected documents will be permanently deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "Selected items have been moved." +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx msgid "Selected Recipient" msgstr "选定收件人" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Selected templates will be permanently deleted" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8812,6 +8931,14 @@ msgstr "模板已上传" msgid "Templates" msgstr "模板" +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates deleted" +msgstr "" + +#: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +msgid "Templates partially deleted" +msgstr "" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Test" msgstr "测试" @@ -9009,6 +9136,10 @@ msgstr "您尝试移动的文件夹不存在。" msgid "The folder you are trying to move the document to does not exist." msgstr "您尝试移动文档到的文件夹不存在。" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "The folder you are trying to move the items to does not exist." +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "The folder you are trying to move the template to does not exist." msgstr "您尝试移动模板到的文件夹不存在。" @@ -10396,7 +10527,7 @@ msgstr "查看邀请" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "查看 JSON" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11103,6 +11234,10 @@ msgstr "你正在更新通行密钥 <0>{passkeyName}。" msgid "You are currently updating the <0>{teamGroupName} team group." msgstr "您当前正在更新 <0>{teamGroupName} 团队组。" +#: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx +msgid "You are not allowed to move these items." +msgstr "" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx msgid "You are not allowed to move this document." msgstr "您无权移动此文档。" diff --git a/packages/lib/types/field-meta.ts b/packages/lib/types/field-meta.ts index 96de93634..e4ed9be40 100644 --- a/packages/lib/types/field-meta.ts +++ b/packages/lib/types/field-meta.ts @@ -1,4 +1,3 @@ -import { msg } from '@lingui/core/macro'; import { FieldType } from '@prisma/client'; import { z } from 'zod'; @@ -88,7 +87,7 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({ type: z.literal('text'), text: z.string().optional(), characterLimit: z.coerce - .number({ invalid_type_error: msg`Value must be a number`.id }) + .number({ invalid_type_error: 'Value must be a number' }) .min(0) .optional(), textAlign: ZFieldTextAlignSchema.optional(), diff --git a/packages/lib/types/license.ts b/packages/lib/types/license.ts new file mode 100644 index 000000000..b0194fff3 --- /dev/null +++ b/packages/lib/types/license.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; + +/** + * Note: Keep this in sync with the Documenso License Server schemas. + */ +export const ZLicenseClaimSchema = z.object({ + emailDomains: z.boolean().optional(), + embedAuthoring: z.boolean().optional(), + embedAuthoringWhiteLabel: z.boolean().optional(), + cfr21: z.boolean().optional(), + authenticationPortal: z.boolean().optional(), + billing: z.boolean().optional(), +}); + +/** + * Note: Keep this in sync with the Documenso License Server schemas. + */ +export const ZLicenseRequestSchema = z.object({ + license: z.string().min(1, 'License key is required'), +}); + +/** + * Note: Keep this in sync with the Documenso License Server schemas. + */ +export const ZLicenseResponseSchema = z.object({ + success: z.boolean(), + // Note that this is nullable, null means license was not found. + data: z + .object({ + status: z.enum(['ACTIVE', 'EXPIRED', 'PAST_DUE']), + createdAt: z.coerce.date(), + name: z.string(), + periodEnd: z.coerce.date(), + cancelAtPeriodEnd: z.boolean(), + licenseKey: z.string(), + flags: ZLicenseClaimSchema, + }) + .nullable(), +}); + +export type TLicenseClaim = z.infer; +export type TLicenseRequest = z.infer; +export type TLicenseResponse = z.infer; + +/** + * Schema for the cached license data stored in the file. + */ +export const ZCachedLicenseSchema = z.object({ + /** + * The last time the license was synced. + */ + lastChecked: z.string(), + + /** + * The raw license response from the license server. + */ + license: ZLicenseResponseSchema.shape.data, + + /** + * The license key that is currently stored on the system environment variable. + */ + requestedLicenseKey: z.string().optional(), + + /** + * Whether the current license has unauthorized flag usage. + */ + unauthorizedFlagUsage: z.boolean(), + + /** + * The derived status of the license. This is calculated based on the license response and the unauthorized flag usage. + */ + derivedStatus: z.enum([ + 'UNAUTHORIZED', // Unauthorized flag usage detected, overrides everything except PAST_DUE since that's a grace period. + 'ACTIVE', // License is active and everything is good. + 'EXPIRED', // License is expired and there is no unauthorized flag usage. + 'PAST_DUE', // License is past due. + 'NOT_FOUND', // Requested license key is not found. + ]), +}); + +export type TCachedLicense = z.infer; + +export const LICENSE_FILE_NAME = '.documenso-license.json'; diff --git a/packages/lib/types/subscription.ts b/packages/lib/types/subscription.ts index eba408cd8..580994428 100644 --- a/packages/lib/types/subscription.ts +++ b/packages/lib/types/subscription.ts @@ -42,6 +42,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< { label: string; key: keyof TClaimFlags; + isEnterprise?: boolean; } > = { unlimitedDocuments: { @@ -59,10 +60,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< emailDomains: { key: 'emailDomains', label: 'Email domains', + isEnterprise: true, }, embedAuthoring: { key: 'embedAuthoring', label: 'Embed authoring', + isEnterprise: true, }, embedSigning: { key: 'embedSigning', @@ -71,6 +74,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< embedAuthoringWhiteLabel: { key: 'embedAuthoringWhiteLabel', label: 'White label for embed authoring', + isEnterprise: true, }, embedSigningWhiteLabel: { key: 'embedSigningWhiteLabel', @@ -79,10 +83,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< cfr21: { key: 'cfr21', label: '21 CFR', + isEnterprise: true, }, authenticationPortal: { key: 'authenticationPortal', label: 'Authentication portal', + isEnterprise: true, }, allowLegacyEnvelopes: { key: 'allowLegacyEnvelopes', diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 8e71040ab..c4b91c8bb 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -26,7 +26,7 @@ "nanoid": "^5.1.6", "prisma": "^6.19.0", "prisma-extension-kysely": "^3.0.0", - "prisma-kysely": "^2.2.1", + "prisma-kysely": "^2.3.0", "prisma-json-types-generator": "^3.6.2", "ts-pattern": "^5.9.0", "zod": "^3.25.76", @@ -38,4 +38,4 @@ "tsx": "^4.20.6", "typescript": "5.6.2" } -} \ No newline at end of file +} diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 139ee58f2..97355c419 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -21,7 +21,7 @@ "formidable": "^3.5.4", "luxon": "^3.7.2", "superjson": "^2.2.5", - "trpc-to-openapi": "2.4.0", + "trpc-to-openapi": "^2.1.5", "ts-pattern": "^5.9.0", "zod": "^3.25.76", "zod-form-data": "^2.0.8", diff --git a/packages/trpc/server/admin-router/resync-license.ts b/packages/trpc/server/admin-router/resync-license.ts new file mode 100644 index 000000000..64048ce2c --- /dev/null +++ b/packages/trpc/server/admin-router/resync-license.ts @@ -0,0 +1,17 @@ +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; + +import { adminProcedure } from '../trpc'; +import { ZResyncLicenseRequestSchema, ZResyncLicenseResponseSchema } from './resync-license.types'; + +export const resyncLicenseRoute = adminProcedure + .input(ZResyncLicenseRequestSchema) + .output(ZResyncLicenseResponseSchema) + .mutation(async () => { + const client = LicenseClient.getInstance(); + + if (!client) { + return; + } + + await client.resync(); + }); diff --git a/packages/trpc/server/admin-router/resync-license.types.ts b/packages/trpc/server/admin-router/resync-license.types.ts new file mode 100644 index 000000000..652d4b588 --- /dev/null +++ b/packages/trpc/server/admin-router/resync-license.types.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const ZResyncLicenseRequestSchema = z.void(); + +export const ZResyncLicenseResponseSchema = z.void(); + +export type TResyncLicenseRequest = z.infer; +export type TResyncLicenseResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 7e1a7d5d7..c169e4813 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -17,6 +17,7 @@ import { getUserRoute } from './get-user'; import { promoteMemberToOwnerRoute } from './promote-member-to-owner'; import { resealDocumentRoute } from './reseal-document'; import { resetTwoFactorRoute } from './reset-two-factor-authentication'; +import { resyncLicenseRoute } from './resync-license'; import { updateAdminOrganisationRoute } from './update-admin-organisation'; import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role'; import { updateRecipientRoute } from './update-recipient'; @@ -44,6 +45,9 @@ export const adminRouter = router({ stripe: { createCustomer: createStripeCustomerRoute, }, + license: { + resync: resyncLicenseRoute, + }, user: { get: getUserRoute, update: updateUserRoute, diff --git a/packages/trpc/server/document-router/create-document-temporary.ts b/packages/trpc/server/document-router/create-document-temporary.ts index d6f49a3f8..0eca05823 100644 --- a/packages/trpc/server/document-router/create-document-temporary.ts +++ b/packages/trpc/server/document-router/create-document-temporary.ts @@ -38,6 +38,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure meta, folderId, attachments, + formValues, } = input; const { remaining } = await getServerLimits({ userId: user.id, teamId }); @@ -68,6 +69,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure title, externalId, visibility, + formValues, globalAccessAuth, globalActionAuth, recipients: (recipients || []).map((recipient) => ({ diff --git a/packages/trpc/server/document-router/create-document-temporary.types.ts b/packages/trpc/server/document-router/create-document-temporary.types.ts index 8895e1a99..51445caaa 100644 --- a/packages/trpc/server/document-router/create-document-temporary.types.ts +++ b/packages/trpc/server/document-router/create-document-temporary.types.ts @@ -27,6 +27,7 @@ import { /** * Temporariy endpoint for V2 Beta until we allow passthrough documents on create. + * @deprecated */ export const createDocumentTemporaryMeta: TrpcRouteMeta = { openapi: { @@ -36,6 +37,7 @@ export const createDocumentTemporaryMeta: TrpcRouteMeta = { description: 'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/document-router/create-document.ts b/packages/trpc/server/document-router/create-document.ts index a515db87f..0d88ac22c 100644 --- a/packages/trpc/server/document-router/create-document.ts +++ b/packages/trpc/server/document-router/create-document.ts @@ -78,6 +78,7 @@ export const createDocumentRoute = authenticatedProcedure visibility, globalAccessAuth, globalActionAuth, + formValues, recipients: (recipients || []).map((recipient) => ({ ...recipient, fields: (recipient.fields || []).map((field) => ({ diff --git a/packages/trpc/server/document-router/download-document-audit-logs.ts b/packages/trpc/server/document-router/download-document-audit-logs.ts index 7ce76049e..9da3c2a68 100644 --- a/packages/trpc/server/document-router/download-document-audit-logs.ts +++ b/packages/trpc/server/document-router/download-document-audit-logs.ts @@ -1,11 +1,9 @@ import { EnvelopeType } from '@prisma/client'; -import { DateTime } from 'luxon'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; -import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; +import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf'; import { authenticatedProcedure } from '../trpc'; import { @@ -42,12 +40,26 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure }); } - const encrypted = encryptSecondaryData({ - data: mapSecondaryIdToDocumentId(envelope.secondaryId).toString(), - expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + const certificatePdf = await generateAuditLogPdf({ + envelope, + recipients: envelope.recipients, + fields: envelope.fields, + language: envelope.documentMeta.language, + envelopeOwner: { + email: envelope.user.email, + name: envelope.user.name || '', + }, + envelopeItems: envelope.envelopeItems.map((item) => item.title), + pageWidth: PDF_SIZE_A4_72PPI.width, + pageHeight: PDF_SIZE_A4_72PPI.height, }); + const result = await certificatePdf.save(); + + const base64 = Buffer.from(result).toString('base64'); + return { - url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`, + data: base64, + envelopeTitle: envelope.title, }; }); diff --git a/packages/trpc/server/document-router/download-document-audit-logs.types.ts b/packages/trpc/server/document-router/download-document-audit-logs.types.ts index b4cc209c2..8e0ca39ad 100644 --- a/packages/trpc/server/document-router/download-document-audit-logs.types.ts +++ b/packages/trpc/server/document-router/download-document-audit-logs.types.ts @@ -5,7 +5,8 @@ export const ZDownloadDocumentAuditLogsRequestSchema = z.object({ }); export const ZDownloadDocumentAuditLogsResponseSchema = z.object({ - url: z.string(), + data: z.string(), + envelopeTitle: z.string(), }); export type TDownloadDocumentAuditLogsRequest = z.infer< diff --git a/packages/trpc/server/document-router/download-document-certificate.ts b/packages/trpc/server/document-router/download-document-certificate.ts index e81ec5082..82fc80588 100644 --- a/packages/trpc/server/document-router/download-document-certificate.ts +++ b/packages/trpc/server/document-router/download-document-certificate.ts @@ -1,12 +1,11 @@ import { EnvelopeType } from '@prisma/client'; -import { DateTime } from 'luxon'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { AppError } from '@documenso/lib/errors/app-error'; -import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; -import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; -import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; +import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; import { @@ -27,7 +26,7 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure }, }); - const envelope = await getEnvelopeById({ + const { envelopeWhereInput } = await getEnvelopeWhereInput({ id: { type: 'documentId', id: documentId, @@ -37,16 +36,54 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure teamId, }); + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + include: { + recipients: true, + fields: { + include: { + signature: true, + }, + }, + documentMeta: true, + user: { + select: { + email: true, + name: true, + }, + }, + }, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Envelope not found', + }); + } + if (!isDocumentCompleted(envelope.status)) { throw new AppError('DOCUMENT_NOT_COMPLETE'); } - const encrypted = encryptSecondaryData({ - data: mapSecondaryIdToDocumentId(envelope.secondaryId).toString(), - expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + const certificatePdf = await generateCertificatePdf({ + envelope, + recipients: envelope.recipients, + fields: envelope.fields, + language: envelope.documentMeta.language, + envelopeOwner: { + email: envelope.user.email, + name: envelope.user.name || '', + }, + pageWidth: PDF_SIZE_A4_72PPI.width, + pageHeight: PDF_SIZE_A4_72PPI.height, }); + const result = await certificatePdf.save(); + + const base64 = Buffer.from(result).toString('base64'); + return { - url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`, + data: base64, + envelopeTitle: envelope.title, }; }); diff --git a/packages/trpc/server/document-router/download-document-certificate.types.ts b/packages/trpc/server/document-router/download-document-certificate.types.ts index df81f1cad..ab0aeec22 100644 --- a/packages/trpc/server/document-router/download-document-certificate.types.ts +++ b/packages/trpc/server/document-router/download-document-certificate.types.ts @@ -5,7 +5,8 @@ export const ZDownloadDocumentCertificateRequestSchema = z.object({ }); export const ZDownloadDocumentCertificateResponseSchema = z.object({ - url: z.string(), + data: z.string(), + envelopeTitle: z.string(), }); export type TDownloadDocumentCertificateRequest = z.infer< diff --git a/packages/trpc/server/envelope-router/bulk-delete-envelopes.ts b/packages/trpc/server/envelope-router/bulk-delete-envelopes.ts new file mode 100644 index 000000000..6a8192d66 --- /dev/null +++ b/packages/trpc/server/envelope-router/bulk-delete-envelopes.ts @@ -0,0 +1,110 @@ +import { EnvelopeType } from '@prisma/client'; +import pMap from 'p-map'; + +import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; +import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids'; +import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZBulkDeleteEnvelopesRequestSchema, + ZBulkDeleteEnvelopesResponseSchema, +} from './bulk-delete-envelopes.types'; + +export const bulkDeleteEnvelopesRoute = authenticatedProcedure + // .meta(bulkDeleteEnvelopesMeta) // Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized. + .input(ZBulkDeleteEnvelopesRequestSchema) + .output(ZBulkDeleteEnvelopesResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { envelopeIds } = input; + + ctx.logger.info({ + input: { + envelopeIds, + }, + }); + + const { envelopeWhereInput } = await getMultipleEnvelopeWhereInput({ + ids: { + type: 'envelopeId', + ids: envelopeIds, + }, + userId: user.id, + teamId, + type: null, + }); + + const envelopes = await prisma.envelope.findMany({ + where: envelopeWhereInput, + select: { + id: true, + type: true, + }, + }); + + const results = await pMap( + envelopes, + async (envelope) => { + const { id: envelopeId, type: envelopeType } = envelope; + + try { + if (envelopeType === EnvelopeType.DOCUMENT) { + await deleteDocument({ + id: { + type: 'envelopeId', + id: envelopeId, + }, + userId: user.id, + teamId, + requestMetadata: ctx.metadata, + }); + } else if (envelopeType === EnvelopeType.TEMPLATE) { + await deleteTemplate({ + id: { + type: 'envelopeId', + id: envelopeId, + }, + userId: user.id, + teamId, + }); + } + + return { + success: true, + envelopeId, + }; + } catch (err) { + ctx.logger.warn( + { + envelopeId, + error: err, + }, + 'Failed to delete envelope during bulk delete', + ); + + return { + success: false, + envelopeId, + }; + } + }, + { + concurrency: 10, + stopOnError: false, + }, + ); + + const deletedCount = results.filter((r) => r.success).length; + const failedIds = results.filter((r) => !r.success).map((r) => r.envelopeId); + + // Include envelope IDs that were not attempted (unauthorized/not found) + const attemptedIds = new Set(envelopes.map((e) => e.id)); + const unattemptedIds = envelopeIds.filter((id) => !attemptedIds.has(id)); + + return { + deletedCount, + failedIds: [...failedIds, ...unattemptedIds], + }; + }); diff --git a/packages/trpc/server/envelope-router/bulk-delete-envelopes.types.ts b/packages/trpc/server/envelope-router/bulk-delete-envelopes.types.ts new file mode 100644 index 000000000..1a75bc833 --- /dev/null +++ b/packages/trpc/server/envelope-router/bulk-delete-envelopes.types.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +// READ ME: IF YOU UNCOMMENT THIS THEN UNSKIP THE TEST IN api-access-envelope-bulk.spec.ts +// Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized. +// export const bulkDeleteEnvelopesMeta: TrpcRouteMeta = { +// openapi: { +// method: 'POST', +// path: '/envelope/bulk/delete', +// summary: 'Bulk delete envelopes', +// description: 'Delete multiple envelopes.', +// tags: ['Envelopes'], +// }, +// }; + +export const ZBulkDeleteEnvelopesRequestSchema = z.object({ + envelopeIds: z + .array(z.string()) + .min(1) + .max(100) + .describe( + 'The IDs of the envelopes to delete. The maximum number of envelopes you can delete at once is 100.', + ), +}); + +export const ZBulkDeleteEnvelopesResponseSchema = z.object({ + deletedCount: z.number(), + failedIds: z.array(z.string()), +}); + +export type TBulkDeleteEnvelopesRequest = z.infer; +export type TBulkDeleteEnvelopesResponse = z.infer; diff --git a/packages/trpc/server/envelope-router/bulk-move-envelopes.ts b/packages/trpc/server/envelope-router/bulk-move-envelopes.ts new file mode 100644 index 000000000..6cf8594df --- /dev/null +++ b/packages/trpc/server/envelope-router/bulk-move-envelopes.ts @@ -0,0 +1,73 @@ +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getMultipleEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelopes-by-ids'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZBulkMoveEnvelopesRequestSchema, + ZBulkMoveEnvelopesResponseSchema, +} from './bulk-move-envelopes.types'; + +export const bulkMoveEnvelopesRoute = authenticatedProcedure + // .meta(bulkMoveEnvelopesMeta) + .input(ZBulkMoveEnvelopesRequestSchema) + .output(ZBulkMoveEnvelopesResponseSchema) + .mutation(async ({ input, ctx }) => { + const { teamId, user } = ctx; + const { envelopeIds, envelopeType, folderId } = input; + + ctx.logger.info({ + input: { + envelopeIds, + envelopeType, + folderId, + }, + }); + + // Build the where input for the update query. + const { envelopeWhereInput, team } = await getMultipleEnvelopeWhereInput({ + ids: { + type: 'envelopeId', + ids: envelopeIds, + }, + userId: user.id, + teamId, + type: envelopeType, + }); + + // Validate folder access if moving to a folder (not root). + if (folderId) { + const folder = await prisma.folder.findFirst({ + where: { + id: folderId, + team: buildTeamWhereQuery({ + teamId, + userId: user.id, + }), + type: envelopeType, + visibility: { + in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], + }, + }, + }); + + if (!folder) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Folder not found or access denied', + }); + } + } + + const result = await prisma.envelope.updateMany({ + where: envelopeWhereInput, + data: { + folderId: folderId, + }, + }); + + return { + movedCount: result.count, + }; + }); diff --git a/packages/trpc/server/envelope-router/bulk-move-envelopes.types.ts b/packages/trpc/server/envelope-router/bulk-move-envelopes.types.ts new file mode 100644 index 000000000..7c62a0bd3 --- /dev/null +++ b/packages/trpc/server/envelope-router/bulk-move-envelopes.types.ts @@ -0,0 +1,38 @@ +import { EnvelopeType } from '@prisma/client'; +import { z } from 'zod'; + +// READ ME: IF YOU UNCOMMENT THIS THEN UNSKIP THE TEST IN api-access-envelope-bulk.spec.ts +// Keeping this as a private API for a little while until we're sure it's stable and the request/response schemas are finalized. +// export const bulkMoveEnvelopesMeta: TrpcRouteMeta = { +// openapi: { +// method: 'POST', +// path: '/envelope/bulk/move', +// summary: 'Bulk move envelopes to folder', +// description: 'Move multiple envelopes to a specified folder.', +// tags: ['Envelopes'], +// }, +// }; + +export const ZBulkMoveEnvelopesRequestSchema = z.object({ + envelopeIds: z + .array(z.string()) + .min(1) + .max(100) + .describe( + 'The IDs of the envelopes to move. The maximum number of envelopes you can move at once is 100.', + ), + envelopeType: z.nativeEnum(EnvelopeType).describe('The type of the envelopes being moved.'), + folderId: z + .string() + .nullable() + .describe( + 'The ID of the folder to move the envelopes to. If null envelopes will be moved to the root folder.', + ), +}); + +export const ZBulkMoveEnvelopesResponseSchema = z.object({ + movedCount: z.number().describe('The number of envelopes that were moved.'), +}); + +export type TBulkMoveEnvelopesRequest = z.infer; +export type TBulkMoveEnvelopesResponse = z.infer; diff --git a/packages/trpc/server/envelope-router/create-envelope-items.ts b/packages/trpc/server/envelope-router/create-envelope-items.ts index 46b15d8e4..5300e9b44 100644 --- a/packages/trpc/server/envelope-router/create-envelope-items.ts +++ b/packages/trpc/server/envelope-router/create-envelope-items.ts @@ -1,8 +1,15 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { + convertPlaceholdersToFieldInputs, + extractPdfPlaceholders, +} from '@documenso/lib/server-only/pdf/auto-place-fields'; +import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers'; +import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf'; +import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { prefixedId } from '@documenso/lib/universal/id'; -import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; +import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; @@ -84,14 +91,31 @@ export const createEnvelopeItemsRoute = authenticatedProcedure }); } - // For each file, stream to s3 and create the document data. + // For each file: normalize, extract & clean placeholders, then upload. const envelopeItems = await Promise.all( files.map(async (file) => { - const { id: documentDataId } = await putNormalizedPdfFileServerSide(file); + let buffer = Buffer.from(await file.arrayBuffer()); + + if (envelope.formValues) { + buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues }); + } + + const normalized = await normalizePdf(buffer, { + flattenForm: envelope.type !== 'TEMPLATE', + }); + + const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized); + + const { id: documentDataId } = await putPdfFileServerSide({ + name: file.name, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(cleanedPdf), + }); return { title: file.name, documentDataId, + placeholders, }; }), ); @@ -131,6 +155,65 @@ export const createEnvelopeItemsRoute = authenticatedProcedure ), }); + // Create fields from placeholders if the envelope already has recipients. + if (envelope.recipients.length > 0) { + const orderedRecipients = [...envelope.recipients].sort((a, b) => { + const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER; + const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER; + + if (aOrder !== bOrder) { + return aOrder - bOrder; + } + + return a.id - b.id; + }); + + for (const uploadedItem of envelopeItems) { + if (!uploadedItem.placeholders || uploadedItem.placeholders.length === 0) { + continue; + } + + const createdItem = createdItems.find( + (ci) => ci.documentDataId === uploadedItem.documentDataId, + ); + + if (!createdItem) { + continue; + } + + const fieldsToCreate = convertPlaceholdersToFieldInputs( + uploadedItem.placeholders, + (recipientPlaceholder, placeholder) => + findRecipientByPlaceholder( + recipientPlaceholder, + placeholder, + orderedRecipients, + orderedRecipients, + ), + createdItem.id, + ); + + if (fieldsToCreate.length > 0) { + await tx.field.createMany({ + data: fieldsToCreate.map((field) => ({ + envelopeId: envelope.id, + envelopeItemId: createdItem.id, + recipientId: field.recipientId, + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: '', + inserted: false, + fieldMeta: field.fieldMeta || undefined, + })), + }); + } + } + } + return createdItems; }); diff --git a/packages/trpc/server/envelope-router/create-envelope.ts b/packages/trpc/server/envelope-router/create-envelope.ts index 60f98962d..ead1debfa 100644 --- a/packages/trpc/server/envelope-router/create-envelope.ts +++ b/packages/trpc/server/envelope-router/create-envelope.ts @@ -3,7 +3,9 @@ import { EnvelopeType } from '@prisma/client'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope'; -import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; +import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-place-fields'; +import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf'; +import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf'; import { authenticatedProcedure } from '../trpc'; @@ -69,7 +71,7 @@ export const createEnvelopeRoute = authenticatedProcedure }); } - // For each file, stream to s3 and create the document data. + // For each file: normalize, extract & clean placeholders, then upload. const envelopeItems = await Promise.all( files.map(async (file) => { let pdf = Buffer.from(await file.arrayBuffer()); @@ -82,20 +84,22 @@ export const createEnvelopeRoute = authenticatedProcedure }); } - const { id: documentDataId } = await putNormalizedPdfFileServerSide( - { - name: file.name, - type: 'application/pdf', - arrayBuffer: async () => Promise.resolve(pdf), - }, - { - flattenForm: type !== EnvelopeType.TEMPLATE, - }, - ); + const normalized = await normalizePdf(pdf, { + flattenForm: type !== EnvelopeType.TEMPLATE, + }); + + const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized); + + const { id: documentDataId } = await putPdfFileServerSide({ + name: file.name, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(cleanedPdf), + }); return { title: file.name, documentDataId, + placeholders, }; }), ); diff --git a/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.types.ts b/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.types.ts index 065195d04..f1f4e9caa 100644 --- a/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.types.ts +++ b/packages/trpc/server/envelope-router/envelope-fields/create-envelope-fields.types.ts @@ -22,7 +22,7 @@ export const createEnvelopeFieldsMeta: TrpcRouteMeta = { }, }; -const ZCreateFieldSchema = ZEnvelopeFieldAndMetaSchema.and( +const ZCreateFieldBaseSchema = ZEnvelopeFieldAndMetaSchema.and( z.object({ recipientId: z.number().describe('The ID of the recipient to create the field for'), envelopeItemId: z @@ -31,14 +31,51 @@ const ZCreateFieldSchema = ZEnvelopeFieldAndMetaSchema.and( .describe( 'The ID of the envelope item to put the field on. If not provided, field will be placed on the first item.', ), - page: ZFieldPageNumberSchema, - positionX: ZClampedFieldPositionXSchema, - positionY: ZClampedFieldPositionYSchema, - width: ZClampedFieldWidthSchema, - height: ZClampedFieldHeightSchema, }), ); +/** + * Position a field using explicit percentage-based coordinates. + */ +const ZCoordinatePositionSchema = z.object({ + page: ZFieldPageNumberSchema, + positionX: ZClampedFieldPositionXSchema, + positionY: ZClampedFieldPositionYSchema, + width: ZClampedFieldWidthSchema, + height: ZClampedFieldHeightSchema, +}); + +/** + * Position a field using a PDF text placeholder (e.g. "{{name}}"). + * + * The placeholder text is matched in the envelope item's PDF and the field is + * placed at the bounding box of that match. Width and height can optionally be + * overridden; when omitted the dimensions of the placeholder text are used. + */ +const ZPlaceholderPositionSchema = z.object({ + placeholder: z + .string() + .describe( + 'Text to search for in the PDF (e.g. "{{name}}"). The field will be placed at the location of this text.', + ), + width: ZClampedFieldWidthSchema.optional().describe( + 'Override the width of the field. When omitted, the width of the placeholder text is used.', + ), + height: ZClampedFieldHeightSchema.optional().describe( + 'Override the height of the field. When omitted, the height of the placeholder text is used.', + ), + matchAll: z + .boolean() + .optional() + .describe( + 'When true, creates a field at every occurrence of the placeholder in the PDF. When false or omitted, only the first occurrence is used.', + ), +}); + +const ZCreateFieldSchema = ZCreateFieldBaseSchema.and( + z.union([ZCoordinatePositionSchema, ZPlaceholderPositionSchema]), +); + export const ZCreateEnvelopeFieldsRequestSchema = z.object({ envelopeId: z.string(), data: ZCreateFieldSchema.array(), diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index 78131f093..68369b118 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -3,6 +3,8 @@ import { createAttachmentRoute } from './attachment/create-attachment'; import { deleteAttachmentRoute } from './attachment/delete-attachment'; import { findAttachmentsRoute } from './attachment/find-attachments'; import { updateAttachmentRoute } from './attachment/update-attachment'; +import { bulkDeleteEnvelopesRoute } from './bulk-delete-envelopes'; +import { bulkMoveEnvelopesRoute } from './bulk-move-envelopes'; import { createEnvelopeRoute } from './create-envelope'; import { createEnvelopeItemsRoute } from './create-envelope-items'; import { deleteEnvelopeRoute } from './delete-envelope'; @@ -72,6 +74,10 @@ export const envelopeRouter = router({ auditLog: { find: findEnvelopeAuditLogsRoute, }, + bulk: { + move: bulkMoveEnvelopesRoute, + delete: bulkDeleteEnvelopesRoute, + }, get: getEnvelopeRoute, getMany: getEnvelopesByIdsRoute, create: createEnvelopeRoute, diff --git a/packages/trpc/server/organisation-router/delete-organisation-members.ts b/packages/trpc/server/organisation-router/delete-organisation-members.ts index a73991772..9725ecca7 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-members.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-members.ts @@ -100,13 +100,15 @@ export const deleteOrganisationMembers = async ({ organisationId, }, }); + }); + for (const member of membersToDelete) { await jobs.triggerJob({ name: 'send.organisation-member-left.email', payload: { organisationId, - memberUserId: userId, + memberUserId: member.userId, }, }); - }); + } }; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 3f903eb49..eca57177d 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -90,10 +90,12 @@ export const profileRouter = router({ const parsedTeamId = teamId ? Number(teamId) : null; - if (Number.isNaN(parsedTeamId)) { - throw new AppError(AppErrorCode.INVALID_BODY, { - message: 'Invalid team ID provided', - }); + if (typeof parsedTeamId === 'number') { + if (Number.isNaN(parsedTeamId) || parsedTeamId <= 0) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'Invalid team ID provided', + }); + } } return await submitSupportTicket({ diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 5a79b386f..4398d5fd2 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -2,6 +2,7 @@ declare namespace NodeJS { export interface ProcessEnv { PORT?: string; NEXT_PUBLIC_WEBAPP_URL?: string; + NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY?: string; NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string; NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string; diff --git a/packages/ui/primitives/data-table.tsx b/packages/ui/primitives/data-table.tsx index 22a2bf12a..140293755 100644 --- a/packages/ui/primitives/data-table.tsx +++ b/packages/ui/primitives/data-table.tsx @@ -4,6 +4,7 @@ import { Trans } from '@lingui/react/macro'; import type { ColumnDef, PaginationState, + RowSelectionState, Table as TTable, Updater, VisibilityState, @@ -15,7 +16,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '. export type DataTableChildren = (_table: TTable) => React.ReactNode; -export type { ColumnDef as DataTableColumnDef } from '@tanstack/react-table'; +export type { ColumnDef as DataTableColumnDef, RowSelectionState } from '@tanstack/react-table'; export interface DataTableProps { columns: ColumnDef[]; @@ -40,6 +41,10 @@ export interface DataTableProps { enable: boolean; component?: React.ReactNode; }; + enableRowSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: (selection: RowSelectionState) => void; + getRowId?: (row: TData) => string; } export function DataTable({ @@ -58,6 +63,10 @@ export function DataTable({ rowClassName, children, emptyState, + enableRowSelection, + rowSelection, + onRowSelectionChange, + getRowId, }: DataTableProps) { const pagination = useMemo(() => { if (currentPage !== undefined && perPage !== undefined) { @@ -85,6 +94,13 @@ export function DataTable({ } }; + const onTableRowSelectionChange = (updater: Updater) => { + if (onRowSelectionChange) { + const newSelection = typeof updater === 'function' ? updater(rowSelection ?? {}) : updater; + onRowSelectionChange(newSelection); + } + }; + const table = useReactTable({ data, columns, @@ -92,10 +108,14 @@ export function DataTable({ state: { pagination: manualPagination ? pagination : undefined, columnVisibility, + rowSelection: rowSelection ?? {}, }, manualPagination, pageCount: totalPages, onPaginationChange: onTablePaginationChange, + enableRowSelection, + onRowSelectionChange: onTableRowSelectionChange, + getRowId, }); return ( @@ -162,7 +182,7 @@ export function DataTable({ {hasFilters && onClearFilters !== undefined && ( diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 1a185f1bb..80c0e864b 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -753,7 +753,7 @@ export const AddFieldsFormPartial = ({ )} > - Initials + Initials

            @@ -903,7 +903,7 @@ export const AddFieldsFormPartial = ({ )} > - Radio + Radio

            @@ -928,8 +928,7 @@ export const AddFieldsFormPartial = ({ )} > - {/* Not translated on purpose. */} - Checkbox + Checkbox

            diff --git a/packages/ui/primitives/field-selector.tsx b/packages/ui/primitives/field-selector.tsx index ba7c1f673..b814b5db6 100644 --- a/packages/ui/primitives/field-selector.tsx +++ b/packages/ui/primitives/field-selector.tsx @@ -1,3 +1,4 @@ +import { Trans } from '@lingui/react/macro'; import { FieldType } from '@prisma/client'; import { CalendarDays, @@ -115,7 +116,7 @@ export const FieldSelector = ({ {field.type === FieldType.SIGNATURE && (
            - Signature + Signature
            )} diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx index 13dc55bbd..3a3217fe7 100644 --- a/packages/ui/primitives/template-flow/add-template-fields.tsx +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -797,7 +797,7 @@ export const AddTemplateFieldsFormPartial = ({ )} > - Initials + Initials

            @@ -953,7 +953,7 @@ export const AddTemplateFieldsFormPartial = ({ )} > - Radio + Radio

            @@ -979,8 +979,7 @@ export const AddTemplateFieldsFormPartial = ({ )} > - {/* Not translated on purpose. */} - Checkbox + Checkbox

            diff --git a/turbo.json b/turbo.json index 7f54d9862..8bf840e53 100644 --- a/turbo.json +++ b/turbo.json @@ -50,6 +50,7 @@ "NEXT_PUBLIC_DISABLE_SIGNUP", "NEXT_PRIVATE_PLAIN_API_KEY", "NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT", + "NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY", "NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_DIRECT_DATABASE_URL", "NEXT_PRIVATE_LOGGER_FILE_PATH",