mirror of
https://github.com/documenso/documenso.git
synced 2026-06-29 07:40:49 +10:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef4384423d | |||
| 999942014e | |||
| 194b2134cc | |||
| b8df02750b | |||
| 191170923a | |||
| 4078c6b46d | |||
| abbca79b48 | |||
| d6dd2b3292 | |||
| cfaad6efc9 | |||
| 9a45b3564f | |||
| 8b171c9a30 | |||
| a8efb6f495 | |||
| bc184d445f | |||
| 8dfd548c08 | |||
| 73a7335c89 | |||
| be3e45427f | |||
| db7ffc7461 | |||
| 1c12aed35e | |||
| 921e0a0de6 | |||
| 335fee09a9 |
@@ -211,3 +211,17 @@ NEXT_PRIVATE_LOGGER_FILE_PATH=
|
||||
|
||||
# [[PLAIN SUPPORT]]
|
||||
NEXT_PRIVATE_PLAIN_API_KEY=
|
||||
|
||||
# [[DOCUMENT CONVERSION]]
|
||||
# OPTIONAL: Base URL of a Gotenberg-compatible service used to convert uploaded
|
||||
# DOCX files to PDF on the server. When unset, DOCX uploads are disabled and
|
||||
# only PDF is accepted. The dev docker compose exposes Gotenberg on port 3005.
|
||||
# NEXT_PRIVATE_DOCUMENT_CONVERSION_URL="http://localhost:3005"
|
||||
# OPTIONAL: Per-request timeout in milliseconds for the conversion service.
|
||||
# Defaults to 30000 (30s) if unset.
|
||||
# NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS=30000
|
||||
# OPTIONAL: HTTP Basic auth credentials for the conversion service. Set both
|
||||
# when the service is started with `--api-enable-basic-auth` (the dev compose
|
||||
# does this; the matching values there are `documenso` / `password`).
|
||||
# NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
|
||||
# NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=password
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: 'Setup node and cache node_modules'
|
||||
name: 'Setup node'
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
@@ -16,25 +16,7 @@ runs:
|
||||
shell: bash
|
||||
run: corepack enable npm
|
||||
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: npm-
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v3
|
||||
id: cache-node-modules
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
apps/*/node_modules
|
||||
key: modules-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
npm ci --no-audit
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
name: Install playwright binaries
|
||||
description: 'Install playwright, cache and restore if necessary'
|
||||
description: 'Install playwright'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Cache playwright
|
||||
id: cache-playwright
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
${{ github.workspace }}/node_modules/playwright
|
||||
key: playwright-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: playwright-
|
||||
|
||||
- name: Install playwright
|
||||
if: steps.cache-playwright.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps
|
||||
shell: bash
|
||||
|
||||
@@ -41,14 +41,6 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -56,13 +48,3 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
tags: documenso-${{ github.sha }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
- # Temp fix
|
||||
# https://github.com/docker/build-push-action/issues/252
|
||||
# https://github.com/moby/buildkit/issues/1896
|
||||
name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: npm
|
||||
|
||||
- name: Install Octokit
|
||||
run: npm install @octokit/rest@18
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: 'PR Labeler'
|
||||
|
||||
on:
|
||||
- pull_request_target
|
||||
- pull_request
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: npm
|
||||
|
||||
- name: Install Octokit
|
||||
run: npm install @octokit/rest@18
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: 'Validate PR Name'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
·
|
||||
<a href="https://documenso.com">Website</a>
|
||||
·
|
||||
<a href="https://docs.documenso.com">Documentation</a>
|
||||
·
|
||||
<a href="https://github.com/documenso/documenso/issues">Issues</a>
|
||||
·
|
||||
<a href="https://documen.so/live">Upcoming Releases</a>
|
||||
@@ -146,45 +148,7 @@ npm run d
|
||||
|
||||
### Manual Setup
|
||||
|
||||
Follow these steps to setup Documenso on your local machine:
|
||||
|
||||
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
|
||||
|
||||
After forking the repository, clone it to your local device by using the following command:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/<your-username>/documenso
|
||||
```
|
||||
|
||||
2. Run `npm i` in the root directory
|
||||
|
||||
3. Create your `.env` from the `.env.example`. You can use `cp .env.example .env` to get started with our handpicked defaults.
|
||||
|
||||
4. Set the following environment variables:
|
||||
|
||||
- NEXTAUTH_SECRET
|
||||
- NEXT_PUBLIC_WEBAPP_URL
|
||||
- NEXT_PRIVATE_DATABASE_URL
|
||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||
- NEXT_PRIVATE_SMTP_FROM_ADDRESS
|
||||
|
||||
5. Create the database schema by running `npm run prisma:migrate-dev`
|
||||
|
||||
6. Run `npm run translate:compile` in the root directory to compile lingui
|
||||
|
||||
7. Run `npm run dev` in the root directory to start
|
||||
|
||||
8. Register a new user at http://localhost:3000/signup
|
||||
|
||||
---
|
||||
|
||||
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document.
|
||||
- Optional: Create your own signing certificate.
|
||||
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL), see **[Create your own signing certificate](./SIGNING.md)**.
|
||||
- Optional: Configure job provider for document reminders.
|
||||
- The default local job provider does not support scheduled jobs required for document reminders.
|
||||
- To enable reminders, set `NEXT_PRIVATE_JOBS_PROVIDER=inngest` and provide `NEXT_PRIVATE_INNGEST_EVENT_KEY` in your `.env` file.
|
||||
Follow the [manual setup guide](https://docs.documenso.com/docs/developers/local-development/manual) to configure Documenso on your local machine.
|
||||
|
||||
### Run in Gitpod
|
||||
|
||||
@@ -204,138 +168,44 @@ If you're a visual learner and prefer to watch a video walkthrough of setting up
|
||||
|
||||
## Docker
|
||||
|
||||
We provide a Docker container for Documenso, which is published on both DockerHub and GitHub Container Registry.
|
||||
We provide official Docker images on [DockerHub](https://hub.docker.com/r/documenso/documenso) and [GitHub Container Registry](https://ghcr.io/documenso/documenso).
|
||||
|
||||
- DockerHub: [https://hub.docker.com/r/documenso/documenso](https://hub.docker.com/r/documenso/documenso)
|
||||
- GitHub Container Registry: [https://ghcr.io/documenso/documenso](https://ghcr.io/documenso/documenso)
|
||||
|
||||
You can pull the Docker image from either of these registries and run it with your preferred container hosting provider.
|
||||
|
||||
Please note that you will need to provide environment variables for connecting to the database, mailserver, and so forth.
|
||||
|
||||
For detailed instructions on how to configure and run the Docker container, please refer to the [Docker README](./docker/README.md) in the `docker` directory.
|
||||
For setup instructions, see the [Docker Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker) and [Docker Compose](https://docs.documenso.com/docs/self-hosting/deployment/docker-compose) guides.
|
||||
|
||||
## Self Hosting
|
||||
|
||||
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
|
||||
We support a variety of deployment methods including Docker, Docker Compose, Railway, Kubernetes, and manual deployment.
|
||||
|
||||
### Fetch, configure, and build
|
||||
For full instructions, requirements, and configuration details, see the [Self Hosting documentation](https://docs.documenso.com/docs/self-hosting).
|
||||
|
||||
First, clone the code from Github:
|
||||
### One-Click Deploys
|
||||
|
||||
```
|
||||
git clone https://github.com/documenso/documenso.git
|
||||
```
|
||||
|
||||
Then, inside the `documenso` folder, copy the example env file:
|
||||
|
||||
```
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
The following environment variables must be set:
|
||||
|
||||
- `NEXTAUTH_SECRET`
|
||||
- `NEXT_PUBLIC_WEBAPP_URL`
|
||||
- `NEXT_PRIVATE_DATABASE_URL`
|
||||
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
|
||||
- `NEXT_PRIVATE_SMTP_FROM_NAME`
|
||||
- `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
|
||||
|
||||
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for the `NEXT_PUBLIC_WEBAPP_URL` variable!
|
||||
|
||||
Now you can install the dependencies and build it:
|
||||
|
||||
```
|
||||
npm i
|
||||
npm run build
|
||||
npm run prisma:migrate-deploy
|
||||
```
|
||||
|
||||
Finally, you can start it with:
|
||||
|
||||
```
|
||||
cd apps/remix
|
||||
npm run start
|
||||
```
|
||||
|
||||
This will start the server on `localhost:3000`. For now, any reverse proxy can then do the frontend and SSL termination.
|
||||
|
||||
> If you want to run with another port than 3000, you can start the application with `next -p <ANY PORT>` from the `apps/remix` folder.
|
||||
|
||||
### Run as a service
|
||||
|
||||
You can use a systemd service file to run the app. Here is a simple example of the service running on port 3500 (using 3000 by default):
|
||||
|
||||
```bash
|
||||
[Unit]
|
||||
Description=documenso
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Environment=PATH=/path/to/your/node/binaries
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/documenso/apps/remix
|
||||
ExecStart=/usr/bin/next start -p 3500
|
||||
TimeoutSec=15
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Railway
|
||||
#### Railway
|
||||
|
||||
[](https://railway.app/template/bG6D4p)
|
||||
|
||||
### Render
|
||||
#### Render
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||
|
||||
### Koyeb
|
||||
#### Koyeb
|
||||
|
||||
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
||||
|
||||
## Elestio
|
||||
#### Elestio
|
||||
|
||||
[](https://elest.io/open-source/documenso)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
For troubleshooting self-hosted deployments, see the [Troubleshooting guide](https://docs.documenso.com/docs/self-hosting/maintenance/troubleshooting) and [Tips & Common Pitfalls](https://docs.documenso.com/docs/self-hosting/getting-started/tips).
|
||||
|
||||
### I'm not receiving any emails when using the developer quickstart.
|
||||
|
||||
When using the developer quickstart, an [Inbucket](https://inbucket.org/) server will be spun up in a docker container that will store all outgoing emails locally for you to view.
|
||||
|
||||
The Web UI can be found at http://localhost:9000, while the SMTP port will be on localhost:2500.
|
||||
|
||||
### Support IPv6
|
||||
|
||||
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command
|
||||
|
||||
For local docker run
|
||||
|
||||
```bash
|
||||
docker run -it documenso:latest npm run start -- -H ::
|
||||
```
|
||||
|
||||
For k8s or docker-compose
|
||||
|
||||
```yaml
|
||||
containers:
|
||||
- name: documenso
|
||||
image: documenso:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- npm
|
||||
args:
|
||||
- run
|
||||
- start
|
||||
- --
|
||||
- -H
|
||||
- '::'
|
||||
```
|
||||
|
||||
### I can't see environment variables in my package scripts.
|
||||
|
||||
Wrap your package script with the `with:env` script like such:
|
||||
|
||||
+9
-9
@@ -1,24 +1,24 @@
|
||||
---
|
||||
title: Authoring
|
||||
title: Editor
|
||||
description: Embed document, template, and envelope creation directly in your application.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
In addition to embedding signing, Documenso supports embedded authoring. It allows your users to create and edit documents, templates, and envelopes without leaving your application.
|
||||
In addition to embedding signing, Documenso supports embedded editor. It allows your users to create and edit documents, templates, and envelopes without leaving your application.
|
||||
|
||||
<Callout type="warn">
|
||||
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
Embedded editor is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
also available as a paid add-on for the [Platform Plan](https://documen.so/platform-cta-pricing).
|
||||
Contact sales for access.
|
||||
</Callout>
|
||||
|
||||
## Versions
|
||||
|
||||
Embedded authoring is available in two versions:
|
||||
Embedded editor is available in two versions:
|
||||
|
||||
- **[V1 Authoring](/docs/developers/embedding/authoring/v1)** — Works with V1 Documents and Templates.
|
||||
- **[V2 Authoring](/docs/developers/embedding/authoring/v2)** — Works with Envelopes, which are the unified model for documents and templates.
|
||||
- **[V1 Editor](/docs/developers/embedding/editor/v1)** — Works with V1 Documents and Templates.
|
||||
- **[V2 Editor](/docs/developers/embedding/editor/v2)** — Works with Envelopes, which are the unified model for documents and templates.
|
||||
|
||||
### Comparison
|
||||
|
||||
@@ -32,7 +32,7 @@ Embedded authoring is available in two versions:
|
||||
|
||||
## Presign Tokens
|
||||
|
||||
Before using any authoring component, obtain a presign token from your backend:
|
||||
Before using any editor component, obtain a presign token from your backend:
|
||||
|
||||
```
|
||||
POST /api/v2/embedding/create-presign-token
|
||||
@@ -50,7 +50,7 @@ See the [API documentation](https://openapi.documenso.com/reference#tag/embeddin
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [V1 Authoring](/docs/developers/embedding/authoring/v1) — Create and edit documents and templates using V1 components
|
||||
- [V2 Authoring](/docs/developers/embedding/authoring/v2) — Create and edit envelopes using V2 components
|
||||
- [V1 Editor](/docs/developers/embedding/editor/v1) — Create and edit documents and templates using V1 components
|
||||
- [V2 Editor](/docs/developers/embedding/editor/v2) — Create and edit envelopes using V2 components
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) — Customize the appearance of embedded components
|
||||
- [SDKs](/docs/developers/embedding/sdks) — Framework-specific SDK documentation
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Authoring",
|
||||
"title": "Editor",
|
||||
"pages": ["v1", "v2"]
|
||||
}
|
||||
+9
-9
@@ -1,21 +1,21 @@
|
||||
---
|
||||
title: V1 Authoring
|
||||
title: V1 Editor
|
||||
description: Embed V1 document and template creation directly in your application.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
V1 authoring components allow your users to create and edit documents and templates using the V1 Documents and Templates API without leaving your application.
|
||||
V1 editor components allow your users to create and edit documents and templates using the V1 Documents and Templates API without leaving your application.
|
||||
|
||||
<Callout type="warn">
|
||||
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
Embedded editor is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
also available as a paid add-on for the [Platform Plan](https://documen.so/platform-cta-pricing).
|
||||
Contact sales for access.
|
||||
</Callout>
|
||||
|
||||
## Components
|
||||
|
||||
The SDK provides four V1 authoring components:
|
||||
The SDK provides four V1 editor components:
|
||||
|
||||
| Component | Purpose |
|
||||
| ----------------------- | ----------------------- |
|
||||
@@ -29,7 +29,7 @@ The SDK provides four V1 authoring components:
|
||||
|
||||
## Presign Tokens
|
||||
|
||||
All authoring components require a **presign token** for authentication. See the [Authoring overview](/docs/developers/embedding/authoring) for details on obtaining presign tokens.
|
||||
All editor components require a **presign token** for authentication. See the [Editor overview](/docs/developers/embedding/editor) for details on obtaining presign tokens.
|
||||
|
||||
|
||||
<Callout type="warn">
|
||||
@@ -131,7 +131,7 @@ const TemplateEditor = ({ presignToken, templateId }) => {
|
||||
|
||||
## Props
|
||||
|
||||
### All Authoring Components
|
||||
### All Editor Components
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| ------------------ | --------- | -------- | -------------------------------------------------------- |
|
||||
@@ -143,7 +143,7 @@ const TemplateEditor = ({ presignToken, templateId }) => {
|
||||
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
|
||||
| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) |
|
||||
| `className` | `string` | No | CSS class for the iframe |
|
||||
| `features` | `object` | No | Feature toggles for the authoring experience |
|
||||
| `features` | `object` | No | Feature toggles for the editor experience |
|
||||
|
||||
### Update Components Only
|
||||
|
||||
@@ -157,7 +157,7 @@ const TemplateEditor = ({ presignToken, templateId }) => {
|
||||
|
||||
## Feature Toggles
|
||||
|
||||
Customize what options are available in the authoring experience:
|
||||
Customize what options are available in the editor experience:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateDocumentV1
|
||||
@@ -294,7 +294,7 @@ Pass extra props to the iframe for testing experimental features:
|
||||
## See Also
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Signing embed concepts and props
|
||||
- [V2 Authoring](/docs/developers/embedding/authoring/v2) - V2 envelope authoring
|
||||
- [V2 Editor](/docs/developers/embedding/editor/v2) - V2 envelope editor
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Documents API](/docs/developers/api/documents) - Create documents via API
|
||||
- [Templates API](/docs/developers/api/templates) - Create templates via API
|
||||
+13
-13
@@ -1,21 +1,21 @@
|
||||
---
|
||||
title: V2 Authoring
|
||||
title: V2 Editor
|
||||
description: Embed envelope creation and editing directly in your application.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
V2 authoring components allow your users to create and edit envelopes without leaving your application. Envelopes are the unified model for documents and templates in the V2 API.
|
||||
V2 editor components allow your users to create and edit envelopes without leaving your application. Envelopes are the unified model for documents and templates in the V2 API.
|
||||
|
||||
<Callout type="warn">
|
||||
Embedded authoring is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
Embedded editor is included with [Enterprise](https://documen.so/enterprise-cta) plans. It is
|
||||
also available as a paid add-on for the [Platform Plan](https://documen.so/platform-cta-pricing).
|
||||
Contact sales for access.
|
||||
</Callout>
|
||||
|
||||
## Components
|
||||
|
||||
The SDK provides two V2 authoring components:
|
||||
The SDK provides two V2 editor components:
|
||||
|
||||
| Component | Purpose |
|
||||
| ---------------------- | ------------------------ |
|
||||
@@ -26,7 +26,7 @@ The SDK provides two V2 authoring components:
|
||||
|
||||
## Presign Tokens
|
||||
|
||||
All authoring components require a **presign token** for authentication. See the [Authoring overview](/docs/developers/embedding/authoring) for details on obtaining presign tokens.
|
||||
All editor components require a **presign token** for authentication. See the [Editor overview](/docs/developers/embedding/editor) for details on obtaining presign tokens.
|
||||
|
||||
<Callout type="warn">
|
||||
A presigned token is NOT an API token
|
||||
@@ -100,7 +100,7 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => {
|
||||
|
||||
## Props
|
||||
|
||||
### All V2 Authoring Components
|
||||
### All V2 Editor Components
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
| ---------------- | --------- | -------- | -------------------------------------------------------- |
|
||||
@@ -113,7 +113,7 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => {
|
||||
| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) |
|
||||
| `className` | `string` | No | CSS class for the iframe |
|
||||
| `user` | `object` | No | Current user info. When provided, enables the "Add Myself" button in the recipients list. Object with optional `email` and `name` fields |
|
||||
| `features` | `object` | No | Feature toggles for the authoring experience |
|
||||
| `features` | `object` | No | Feature toggles for the editor experience |
|
||||
|
||||
### Create Component Only
|
||||
|
||||
@@ -132,7 +132,7 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => {
|
||||
|
||||
## Feature Toggles
|
||||
|
||||
V2 authoring provides rich, structured feature toggles organized into sections. Pass a partial configuration to customize the authoring experience — any omitted fields will use their defaults.
|
||||
V2 editor provides rich, structured feature toggles organized into sections. Pass a partial configuration to customize the editor experience — any omitted fields will use their defaults.
|
||||
|
||||
```jsx
|
||||
<EmbedCreateEnvelope
|
||||
@@ -160,7 +160,7 @@ V2 authoring provides rich, structured feature toggles organized into sections.
|
||||
|
||||
### General
|
||||
|
||||
Controls the overall authoring flow and UI:
|
||||
Controls the overall editor flow and UI:
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
| ------------------------------- | --------- | ------- | ------------------------------------------------ |
|
||||
@@ -188,7 +188,7 @@ Controls envelope configuration options. Set to `null` to hide envelope settings
|
||||
|
||||
### Actions
|
||||
|
||||
Controls available actions during authoring:
|
||||
Controls available actions during editing:
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
| ------------------ | --------- | ------- | ------------------------ |
|
||||
@@ -221,7 +221,7 @@ Controls recipient configuration options. Set to `null` to prevent any recipient
|
||||
|
||||
### Disabling Steps
|
||||
|
||||
You can also disable entire steps of the authoring flow. This allows you to skip steps that are not relevant to your use case:
|
||||
You can also disable entire steps of the editor flow. This allows you to skip steps that are not relevant to your use case:
|
||||
|
||||
```jsx
|
||||
<EmbedCreateEnvelope
|
||||
@@ -338,7 +338,7 @@ const EnvelopeManager = ({ presignToken }) => {
|
||||
|
||||
## See Also
|
||||
|
||||
- [Authoring Overview](/docs/developers/embedding/authoring) - V1 vs V2 comparison and presign tokens
|
||||
- [V1 Authoring](/docs/developers/embedding/authoring/v1) - V1 document and template authoring
|
||||
- [Editor Overview](/docs/developers/embedding/editor) - V1 vs V2 comparison and presign tokens
|
||||
- [V1 Editor](/docs/developers/embedding/editor/v1) - V1 document and template editor
|
||||
- [Embedding Overview](/docs/developers/embedding) - Signing embed concepts and props
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
@@ -6,14 +6,14 @@ description: Embed document signing experiences directly in your application usi
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
## Embedded Signing vs Embedded Authoring
|
||||
## Embedded Signing vs Embedded Editor
|
||||
|
||||
Documenso offers two types of embedding:
|
||||
|
||||
- **Embedded Signing** lets you embed the signing experience in your application. Your users sign documents without leaving your site. Available on Teams Plan and above.
|
||||
- **Embedded Authoring** lets you embed document and template _creation and editing_ in your application. This is an [Enterprise](/docs/policies/enterprise-edition) feature (also available as a Platform Plan add-on). See the [Authoring](/docs/developers/embedding/authoring) guide.
|
||||
- **Embedded Editor** lets you embed document and template _creation and editing_ in your application. This is an [Enterprise](/docs/policies/enterprise-edition) feature (also available as a Platform Plan add-on). See the [Editor](/docs/developers/embedding/editor) guide.
|
||||
|
||||
This page covers **embedded signing**. If you need your users to create or edit documents inside your app, see [Authoring](/docs/developers/embedding/authoring).
|
||||
This page covers **embedded signing**. If you need your users to create or edit documents inside your app, see [Editor](/docs/developers/embedding/editor).
|
||||
|
||||
---
|
||||
|
||||
@@ -229,9 +229,9 @@ Receives an object with:
|
||||
href="/docs/developers/embedding/css-variables"
|
||||
/>
|
||||
<Card
|
||||
title="Authoring"
|
||||
title="Editor"
|
||||
description="Embed document and template creation."
|
||||
href="/docs/developers/embedding/authoring"
|
||||
href="/docs/developers/embedding/editor"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Embedding",
|
||||
"pages": ["sdks", "direct-links", "css-variables", "authoring"]
|
||||
"pages": ["sdks", "direct-links", "css-variables", "editor"]
|
||||
}
|
||||
|
||||
@@ -89,4 +89,4 @@ export class SigningComponent {
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -93,4 +93,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -133,4 +133,4 @@ const DocumentSigning = ({ token }: { token: string }) => {
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -93,4 +93,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -101,4 +101,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -104,4 +104,4 @@ See [CSS Variables](/docs/developers/embedding/css-variables) for all available
|
||||
|
||||
- [Embedding Overview](/docs/developers/embedding) - Props reference and concepts
|
||||
- [CSS Variables](/docs/developers/embedding/css-variables) - Customize appearance
|
||||
- [Authoring](/docs/developers/embedding/authoring) - Embed document creation
|
||||
- [Editor](/docs/developers/embedding/editor) - Embed document creation
|
||||
|
||||
@@ -83,6 +83,15 @@ npm run prisma:seed -w @documenso/prisma
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Optional: configure job provider
|
||||
|
||||
The default local job provider does not support scheduled jobs required for document reminders.
|
||||
|
||||
See the [Background Jobs](/docs/self-hosting/configuration/background-jobs) page for more information.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Start the application
|
||||
|
||||
@@ -105,6 +114,20 @@ Access the Documenso application by visiting `http://localhost:3000` in your web
|
||||
certificate](/docs/developers/local-development/signing-certificate)**.
|
||||
</Callout>
|
||||
|
||||
## Running Scripts with Environment Variables
|
||||
|
||||
If a package script does not automatically load your `.env` and `.env.local` files, wrap it with the `with:env` script:
|
||||
|
||||
```bash
|
||||
npm run with:env -- npm run myscript
|
||||
```
|
||||
|
||||
The same works for `npx` when running bin scripts:
|
||||
|
||||
```bash
|
||||
npm run with:env -- npx myscript
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Developer Quickstart](/docs/developers/local-development/quickstart) - Quick Docker-based setup
|
||||
|
||||
@@ -53,8 +53,8 @@ The Enterprise Edition is required when you:
|
||||
- Document Action Reauthentication (Passkeys and 2FA)
|
||||
- 21 CFR Part 11 Compliance
|
||||
- Email Domains (custom sender addresses)
|
||||
- Embed Authoring
|
||||
- Embed Authoring White Label
|
||||
- Embed Editor
|
||||
- Embed Editor White Label
|
||||
- Custom signing certificates
|
||||
- Priority feature requests
|
||||
|
||||
|
||||
@@ -19,16 +19,19 @@ Use the limitless plans as much as you like. They are meant to offer a lot. Plea
|
||||
|
||||
### Do
|
||||
|
||||
- Sign as many documents as you need with the individual plan for your single business or organisation
|
||||
- Use the API and automation tools to automate your signing workflows
|
||||
- Experiment with plans and integrations while testing what you want to build
|
||||
- Use team or platform plans to run your workflows, even with significant volume, as long as it aligns with the plan’s intended purpose.
|
||||
- Experiment and automate freely within the plan features.
|
||||
- If volume grows beyond what’s sustainable on your plan, we’ll reach out to discuss an upgrade.
|
||||
- Assume that extreme usage will lead to us contacting you. You can scale up—or scale back. It’s about finding the right fit.
|
||||
|
||||
### Don't
|
||||
|
||||
- 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 on a fair support plan
|
||||
- Overthink this policy — if you are a paying customer, we want you to win
|
||||
- Use an individual account's 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 on a fair support plan (i.e. business edition).
|
||||
- Use a team plan to power an external platform or commercial product or platform beyond moderate testing.
|
||||
- Expect a platform plan to support enterprise-level volumes indefinitely without a conversation.
|
||||
- Don’t expect the platform plan to cover enterprise-scale volume or support. If you reach that point, we’ll reach out to guide you to the right fit.
|
||||
- Don’t overthink this – if you’re building something valuable, we want to see you succeed. If we need to talk, we will.
|
||||
|
||||
## Rate Limits
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: AI Recipient & Field Detection (Self-hosting)
|
||||
title: AI Recipient & Field Detection
|
||||
description: Configure Google Vertex AI so Documenso can detect recipients and fields automatically.
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
---
|
||||
title: Document Conversion
|
||||
description: Enable DOCX uploads on a self-hosted Documenso instance by running a Gotenberg sidecar that converts Word documents to PDF.
|
||||
---
|
||||
|
||||
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
## Overview
|
||||
|
||||
Documenso can accept `.docx` uploads in addition to PDFs. When a user uploads a Word document, the Documenso server sends it to a [Gotenberg](https://gotenberg.dev) service which uses LibreOffice to convert it to PDF. The converted PDF is what gets stored, signed, and downloaded. The original DOCX is discarded.
|
||||
|
||||
This feature is **opt-in for self-hosted instances**. When the conversion service is not configured, DOCX uploads are rejected in the UI and only PDFs are accepted.
|
||||
|
||||
| Property | Value |
|
||||
| ----------------------- | -------------------------------------------------------------------- |
|
||||
| Conversion engine | [Gotenberg](https://gotenberg.dev) + LibreOffice |
|
||||
| Input format | `.docx` (Office Open XML Word documents) |
|
||||
| Output format | PDF |
|
||||
| Network requirement | Documenso must reach the Gotenberg HTTP API |
|
||||
| Default request timeout | 30 seconds per file |
|
||||
| Failure handling | An internal circuit breaker opens for 30 seconds after a failure |
|
||||
|
||||
<Callout type="info">
|
||||
Only `.docx` is accepted. Legacy `.doc`, `.odt`, `.rtf`, and other LibreOffice-supported formats
|
||||
are rejected at the upload step even when Gotenberg is configured.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- A running Gotenberg 8 instance with the LibreOffice module (`gotenberg/gotenberg:8-libreoffice` or newer).
|
||||
- Network reachability from the Documenso container to the Gotenberg HTTP API.
|
||||
- A version of Documenso that includes the document conversion feature.
|
||||
|
||||
## Build the Gotenberg Image
|
||||
|
||||
The upstream `gotenberg/gotenberg:8-libreoffice` image works out of the box, but it ships only **metric-compatible font substitutes** (Carlito for Calibri, Liberation for Arial/Times/Courier). Layout widths are preserved but documents will look noticeably different from Word.
|
||||
|
||||
For better fidelity, especially for non-Latin scripts, build a derived image that adds Microsoft Core Fonts and additional language fonts. The Documenso repository ships a reference Dockerfile at [`docker/development/Dockerfile.gotenberg`](https://github.com/documenso/documenso/blob/main/docker/development/Dockerfile.gotenberg) that you can use as a starting point:
|
||||
|
||||
```dockerfile
|
||||
FROM gotenberg/gotenberg:8-libreoffice
|
||||
|
||||
USER root
|
||||
|
||||
RUN echo "deb http://deb.debian.org/debian trixie contrib non-free" \
|
||||
> /etc/apt/sources.list.d/contrib.list \
|
||||
&& echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" \
|
||||
| debconf-set-selections \
|
||||
&& apt-get update -qq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates \
|
||||
ttf-mscorefonts-installer \
|
||||
fonts-symbola \
|
||||
fonts-noto-extra \
|
||||
fonts-hosny-amiri \
|
||||
fonts-thai-tlwg \
|
||||
fonts-sil-padauk \
|
||||
fonts-sarai \
|
||||
fonts-samyak-taml \
|
||||
culmus \
|
||||
libfribidi0 \
|
||||
libharfbuzz0b \
|
||||
&& fc-cache -f \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
USER gotenberg
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
`ttf-mscorefonts-installer` accepts the Microsoft Core Fonts EULA on your behalf via debconf. By
|
||||
installing this image you are agreeing to those licence terms. Review them before publishing the
|
||||
image.
|
||||
</Callout>
|
||||
|
||||
Build and publish the image to a registry you control:
|
||||
|
||||
```bash
|
||||
docker build -t registry.example.com/documenso/gotenberg:8 \
|
||||
-f Dockerfile.gotenberg .
|
||||
docker push registry.example.com/documenso/gotenberg:8
|
||||
```
|
||||
|
||||
If you do not need extra fonts, skip the build step entirely and reference `gotenberg/gotenberg:8-libreoffice` directly in the next section.
|
||||
|
||||
## Deploy the Service
|
||||
|
||||
The Gotenberg service should run **alongside** your Documenso container, not exposed to the public internet. The conversion service has no built-in authorisation beyond HTTP Basic auth, so it should sit on a private network or behind your existing reverse proxy.
|
||||
|
||||
<Tabs items={['Docker Compose', 'Kubernetes', 'External Instance']}>
|
||||
<Tab value="Docker Compose">
|
||||
|
||||
Add a `gotenberg` service to the `compose.yml` you use for Documenso:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gotenberg:
|
||||
image: registry.example.com/documenso/gotenberg:8
|
||||
# Or use upstream directly:
|
||||
# image: gotenberg/gotenberg:8-libreoffice
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GOTENBERG_API_BASIC_AUTH_USERNAME: ${GOTENBERG_USERNAME}
|
||||
GOTENBERG_API_BASIC_AUTH_PASSWORD: ${GOTENBERG_PASSWORD}
|
||||
command:
|
||||
- gotenberg
|
||||
- --api-enable-basic-auth
|
||||
- --libreoffice-deny-private-ips
|
||||
- --api-timeout=500s
|
||||
- --libreoffice-auto-start
|
||||
- --libreoffice-start-timeout=300s
|
||||
- --pdfengines-disable-routes
|
||||
- --webhook-disable
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-fsS', 'http://localhost:3000/health']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
documenso:
|
||||
# existing config
|
||||
environment:
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL: http://gotenberg:3000
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME: ${GOTENBERG_USERNAME}
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD: ${GOTENBERG_PASSWORD}
|
||||
depends_on:
|
||||
gotenberg:
|
||||
condition: service_healthy
|
||||
```
|
||||
|
||||
Do **not** publish Gotenberg's port (`3000`) to the host. Documenso reaches it over the internal Docker network using the service name (`http://gotenberg:3000`).
|
||||
|
||||
</Tab>
|
||||
<Tab value="Kubernetes">
|
||||
|
||||
Create a Deployment, Service, and Secret. Example manifests:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: gotenberg-auth
|
||||
namespace: documenso
|
||||
stringData:
|
||||
username: documenso
|
||||
password: replace-me-with-a-strong-password
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: gotenberg
|
||||
namespace: documenso
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels: { app: gotenberg }
|
||||
template:
|
||||
metadata:
|
||||
labels: { app: gotenberg }
|
||||
spec:
|
||||
containers:
|
||||
- name: gotenberg
|
||||
image: registry.example.com/documenso/gotenberg:8
|
||||
args:
|
||||
- gotenberg
|
||||
- --api-enable-basic-auth
|
||||
- --libreoffice-deny-private-ips
|
||||
- --api-timeout=500s
|
||||
- --libreoffice-auto-start
|
||||
- --libreoffice-start-timeout=300s
|
||||
- --pdfengines-disable-routes
|
||||
- --webhook-disable
|
||||
env:
|
||||
- name: GOTENBERG_API_BASIC_AUTH_USERNAME
|
||||
valueFrom: { secretKeyRef: { name: gotenberg-auth, key: username } }
|
||||
- name: GOTENBERG_API_BASIC_AUTH_PASSWORD
|
||||
valueFrom: { secretKeyRef: { name: gotenberg-auth, key: password } }
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
readinessProbe:
|
||||
httpGet: { path: /health, port: 3000 }
|
||||
livenessProbe:
|
||||
httpGet: { path: /health, port: 3000 }
|
||||
initialDelaySeconds: 30
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: gotenberg
|
||||
namespace: documenso
|
||||
spec:
|
||||
selector: { app: gotenberg }
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
```
|
||||
|
||||
Then reference the in-cluster URL from Documenso's environment:
|
||||
|
||||
```
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL=http://gotenberg.documenso.svc.cluster.local:3000
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=replace-me-with-a-strong-password
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab value="External Instance">
|
||||
|
||||
Documenso does not have to colocate with Gotenberg. You can point it at any reachable Gotenberg deployment: a managed instance, a shared internal service, or a Gotenberg-compatible API.
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL=https://gotenberg.internal.example.com
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=replace-me-with-a-strong-password
|
||||
```
|
||||
|
||||
The remote instance must:
|
||||
|
||||
- Expose the LibreOffice route `/forms/libreoffice/convert`.
|
||||
- Be reachable from the Documenso container with low enough latency that the 30 second per-request timeout is comfortable.
|
||||
- Be on a private network or require authentication. Uploaded documents are sent to it as multipart form data and may contain sensitive content.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Recommended Gotenberg Flags
|
||||
|
||||
The flags in the examples above are not arbitrary. Each one matters for a production deployment.
|
||||
|
||||
| Flag | Why it matters |
|
||||
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--api-enable-basic-auth` | Requires HTTP Basic credentials on every API route. Without this, anyone with network access to the container can convert arbitrary documents. |
|
||||
| `--libreoffice-deny-private-ips` | Rejects any outbound fetch LibreOffice tries to make to private, loopback, link-local, or cloud-metadata addresses while processing a document. Mitigates SSRF via malicious `.docx` files that embed `TargetMode="External"` references. Requires Gotenberg 8.32.0. |
|
||||
| `--api-timeout=500s` | Server-side request ceiling. Documenso aborts at 30 s by default, so this is a safety net for very large documents. |
|
||||
| `--libreoffice-auto-start` | Starts LibreOffice at container boot so the first request is not slow. |
|
||||
| `--libreoffice-start-timeout=300s`| Allows LibreOffice up to 5 minutes to come up under load. |
|
||||
| `--pdfengines-disable-routes` | Disables the PDF engines routes Documenso does not use. Shrinks the attack surface. |
|
||||
| `--webhook-disable` | Disables webhook callbacks. Documenso uses synchronous requests only. |
|
||||
|
||||
## Configure Documenso
|
||||
|
||||
Set the following environment variables on the Documenso container and restart it.
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL`| Base URL of the Gotenberg service (e.g., `http://gotenberg:3000`). Leave unset to disable the feature. |
|
||||
|
||||
### Optional
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------------------------- | ------- | -------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME` | | HTTP Basic auth username. Set when Gotenberg runs with `--api-enable-basic-auth`. |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD` | | HTTP Basic auth password. Set together with the username. |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS`| `30000` | Per-request timeout in milliseconds. Increase for very large documents. |
|
||||
|
||||
<Callout type="info">
|
||||
When `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` is set, the public flag
|
||||
`NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED` is derived automatically on server start. You do not
|
||||
need to set it yourself, and setting it manually has no effect.
|
||||
</Callout>
|
||||
|
||||
### Example `.env` Snippet
|
||||
|
||||
```bash
|
||||
# Document conversion (DOCX -> PDF)
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_URL=http://gotenberg:3000
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso
|
||||
NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=replace-me-with-a-strong-password
|
||||
# NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS=60000
|
||||
```
|
||||
|
||||
## Verify the Setup
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
### Restart the Documenso container
|
||||
|
||||
Restart so the new environment variables are picked up.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Confirm Gotenberg is healthy
|
||||
|
||||
From a shell inside the Documenso container or another container on the same network:
|
||||
|
||||
```bash
|
||||
curl -fsS http://gotenberg:3000/health
|
||||
```
|
||||
|
||||
The endpoint is exempt from basic auth and should return `200 OK`.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Upload a test DOCX
|
||||
|
||||
In the Documenso web UI, open **Documents** and try uploading a small `.docx` file. The upload dropzone should accept it, and after a few seconds the editor should open with the converted PDF.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Check the server logs
|
||||
|
||||
Successful conversions log a `document_conversion_attempt` event with `result: "success"`, the duration, and the file size. Failures log the same event with `result: "error"` and an error code (`CONVERSION_SERVICE_UNAVAILABLE`, `CONVERSION_FAILED`, or `UNSUPPORTED_FILE_TYPE`).
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Treat the conversion service as untrusted internal infrastructure.** Documents pass through Gotenberg in plain form. Run it on a private network and require HTTP Basic auth.
|
||||
- **Run with `--libreoffice-deny-private-ips`.** Without this flag, a malicious `.docx` can trigger LibreOffice to fetch URLs from your internal network (SSRF).
|
||||
- **Disable unused routes.** `--pdfengines-disable-routes` and `--webhook-disable` reduce attack surface. Documenso only uses the LibreOffice convert route.
|
||||
- **Do not expose Gotenberg to the public internet.** Even with basic auth, this is a document-processing service with a non-trivial CPU and memory footprint; exposing it invites abuse.
|
||||
- **Rotate credentials.** Rotating the basic auth secret is a config change in both Gotenberg and Documenso, followed by a restart of each.
|
||||
|
||||
## Resource Sizing
|
||||
|
||||
Conversion is CPU- and memory-bound on LibreOffice. As a starting point:
|
||||
|
||||
| Workload | Suggested resources |
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
| Light (a few DOCX per minute) | 1 vCPU, 1 GB RAM |
|
||||
| Moderate (sustained uploads) | 2 vCPU, 2 GB RAM |
|
||||
| Heavy / multi-tenant | Horizontally scale Gotenberg replicas behind a load balancer |
|
||||
|
||||
Gotenberg is stateless. Each container handles one or more concurrent requests independently. Scale horizontally rather than vertically once a single replica is saturated.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<Accordions type="multiple">
|
||||
<Accordion title="DOCX uploads are rejected with 'Only PDF and DOCX files are allowed'">
|
||||
The Documenso server does not see `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL`. Check the value is set
|
||||
on the running container (`docker exec documenso printenv | grep DOCUMENT_CONVERSION`) and
|
||||
restart after changing it.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Uploads fail with 'Document conversion service is currently unavailable'">
|
||||
Documenso could not reach Gotenberg. Verify:
|
||||
|
||||
- The URL in `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` is resolvable from the Documenso container
|
||||
(use the Docker service name or in-cluster DNS, not `localhost`).
|
||||
- Gotenberg's `/health` endpoint returns `200`.
|
||||
- Basic auth credentials match between the two services.
|
||||
|
||||
After repeated failures, an internal circuit breaker opens for 30 seconds. Subsequent uploads
|
||||
will fail fast during that window; this is intentional and self-recovers.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Uploads fail with 'Failed to convert document to PDF'">
|
||||
Gotenberg was reachable but returned a non-2xx response. Check the Gotenberg container logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f gotenberg
|
||||
```
|
||||
|
||||
Common causes: corrupted `.docx` file, exotic embedded objects LibreOffice cannot render, or a
|
||||
file that genuinely exceeded the conversion timeout. Increase
|
||||
`NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS` for very large documents.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Converted PDFs look different from the Word document">
|
||||
LibreOffice is not byte-identical to Microsoft Word. Layout, font metrics, and complex elements
|
||||
(Charts, SmartArt, ActiveX controls) may differ. To improve fidelity:
|
||||
|
||||
- Use the custom Dockerfile in this guide to install Microsoft Core Fonts and additional
|
||||
language fonts.
|
||||
- Make sure any custom fonts referenced by your documents are installed in the Gotenberg image.
|
||||
- For pixel-perfect output, ask users to export to PDF from Word before uploading.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Form controls in the DOCX appear blank or missing">
|
||||
Documenso disables Gotenberg's `exportFormFields` flag during conversion. Word content controls
|
||||
(`<w:sdt>`) become static graphics in the output PDF, which prevents Documenso's later
|
||||
flattening step from making them invisible. This is intentional. Use Documenso fields
|
||||
(signature, text, date, etc.) for anything that needs to be filled in by signers.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Conversion is slow on the first request">
|
||||
LibreOffice starts lazily by default. Pass `--libreoffice-auto-start` to Gotenberg so it warms
|
||||
up at container boot. Allow up to a minute on first start before considering the service
|
||||
unhealthy.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="The circuit breaker keeps opening">
|
||||
Repeated failures open an in-process circuit breaker for 30 seconds. If you see this in
|
||||
production, the underlying problem is the Gotenberg service. Check its logs, resource usage,
|
||||
and connectivity. The breaker is per-process and resets on restart.
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Upload Documents (User Guide)](/docs/users/documents/upload) - End-user view of DOCX uploads
|
||||
- [Environment Variables](/docs/self-hosting/configuration/environment) - Full configuration reference
|
||||
- [Docker Compose Deployment](/docs/self-hosting/deployment/docker-compose) - Compose-based deployment patterns
|
||||
- [Gotenberg Documentation](https://gotenberg.dev/docs/getting-started/introduction) - Upstream Gotenberg docs
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Advanced
|
||||
description: Optional configuration for OAuth providers, AI features, and other advanced settings.
|
||||
description: Optional configuration for OAuth providers, AI features, document conversion, and other advanced settings.
|
||||
---
|
||||
|
||||
<Cards>
|
||||
@@ -14,4 +14,9 @@ description: Optional configuration for OAuth providers, AI features, and other
|
||||
description="Enable AI-powered recipient and field detection."
|
||||
href="/docs/self-hosting/configuration/advanced/ai-features"
|
||||
/>
|
||||
<Card
|
||||
title="Document Conversion"
|
||||
description="Accept DOCX uploads by running a Gotenberg sidecar that converts Word documents to PDF."
|
||||
href="/docs/self-hosting/configuration/advanced/document-conversion"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Advanced",
|
||||
"pages": ["oauth-providers", "ai-features"]
|
||||
"pages": ["oauth-providers", "document-conversion", "ai-features"]
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ You can control who is allowed to create accounts on your instance with the foll
|
||||
- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNUP`**: Set to `true` to block brand-new account creation through the matching SSO provider. Existing users with the provider already linked can still sign in, and existing users can still link the provider to their account. `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` also blocks new-account creation through the organisation authentication portal.
|
||||
- **`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`**: Restrict signups to specific email domains. When set, only users whose email address matches one of the listed domains can create an account. Leave empty to allow all domains.
|
||||
|
||||
Sign-in for existing users is never affected — only the creation of brand-new accounts.
|
||||
Sign-in for existing users is never affected, only the creation of brand-new accounts.
|
||||
|
||||
Both the master switch and the domain allowlist apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error.
|
||||
|
||||
@@ -279,6 +279,23 @@ AI features must also be enabled in organisation/team settings after configurati
|
||||
|
||||
---
|
||||
|
||||
## Document Conversion
|
||||
|
||||
Documenso can accept `.docx` uploads by sending them to a [Gotenberg](https://gotenberg.dev) service that converts them to PDF. When `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` is unset, DOCX uploads are rejected and only PDFs are accepted.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------- |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` | Base URL of the Gotenberg service (e.g., `http://gotenberg:3000`). Unset disables the feature. | |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME` | HTTP Basic auth username. Required when Gotenberg runs with `--api-enable-basic-auth`. | |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD` | HTTP Basic auth password. Set together with the username. | |
|
||||
| `NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS` | Per-request timeout in milliseconds. Increase for very large documents. | `30000` |
|
||||
|
||||
The public flag `NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED` is derived automatically from `NEXT_PRIVATE_DOCUMENT_CONVERSION_URL` on server start. Do not set it manually.
|
||||
|
||||
For setup, image-build instructions, and security recommendations, see [Document Conversion](/docs/self-hosting/configuration/advanced/document-conversion).
|
||||
|
||||
---
|
||||
|
||||
## Background Jobs
|
||||
|
||||
Documenso supports multiple background job providers for processing emails, documents, webhooks, and scheduled tasks.
|
||||
@@ -342,7 +359,7 @@ Telemetry collects only: app version, installation ID, and node ID. No personal
|
||||
|
||||
## Enterprise Features
|
||||
|
||||
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed authoring, and 21 CFR Part 11 compliance.
|
||||
These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance.
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------ | ------------------------------------------------ |
|
||||
|
||||
@@ -26,8 +26,14 @@ docker --version
|
||||
|
||||
## Pulling the Docker Image
|
||||
|
||||
The Documenso image is available on both DockerHub and GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
# DockerHub
|
||||
docker pull documenso/documenso:latest
|
||||
|
||||
# GitHub Container Registry
|
||||
docker pull ghcr.io/documenso/documenso:latest
|
||||
```
|
||||
|
||||
### Available Tags
|
||||
@@ -196,6 +202,14 @@ Documenso provides health check endpoints for monitoring:
|
||||
| `/api/health` | Checks database connectivity and certificate status |
|
||||
| `/api/certificate-status` | Returns whether a signing certificate is configured and usable |
|
||||
|
||||
Both endpoints return a JSON response with a `status` field:
|
||||
|
||||
| Status | Meaning |
|
||||
| ----------- | -------------------------------------------------------------------- |
|
||||
| `"ok"` | Everything is working properly |
|
||||
| `"warning"` | Application is running but there are certificate issues |
|
||||
| `"error"` | Critical issues (database unreachable, missing configuration, etc.) |
|
||||
|
||||
### Docker Health Check
|
||||
|
||||
Add a health check to your container:
|
||||
|
||||
@@ -3,6 +3,8 @@ title: Getting Started
|
||||
description: Requirements and quick start guide for self-hosting Documenso.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
<Cards>
|
||||
<Card
|
||||
title="Requirements"
|
||||
@@ -15,3 +17,11 @@ description: Requirements and quick start guide for self-hosting Documenso.
|
||||
href="/docs/self-hosting/getting-started/quick-start"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
<Callout type="error">
|
||||
**You must generate a signing certificate.** Documenso does not ship with one. Without a
|
||||
certificate, the application starts normally but document signing will fail on completion with
|
||||
errors.
|
||||
|
||||
Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding.
|
||||
</Callout>
|
||||
|
||||
@@ -7,14 +7,29 @@ import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
## What You Need
|
||||
|
||||
Documenso requires the following external services:
|
||||
Documenso requires the following items and external services:
|
||||
|
||||
| Service | Purpose | Minimum Version |
|
||||
| ------------- | ---------------------------- | --------------- |
|
||||
| Signing certificate | Digital signature for documents | N/A |
|
||||
| PostgreSQL | Primary database | 14+ |
|
||||
| SMTP server | Sending emails to recipients | Any |
|
||||
| Reverse proxy | SSL termination, routing | Any |
|
||||
|
||||
|
||||
### Signing Certificate
|
||||
|
||||
<Callout type="error">
|
||||
Documenso does not ship with a signing certificate. Without one, the application starts normally
|
||||
but all document signing will fail. You must generate or provide a `.p12` certificate before going
|
||||
to production.
|
||||
</Callout>
|
||||
|
||||
Every completed document is digitally signed using an X.509 certificate. You can generate a self-signed certificate for free or use one from a Certificate Authority (CA).
|
||||
|
||||
- [Generate a local certificate](/docs/self-hosting/configuration/signing-certificate/local) — step-by-step instructions to create a `.p12` certificate
|
||||
- [All certificate options](/docs/self-hosting/configuration/signing-certificate) — self-signed, CA-issued, and Google Cloud HSM
|
||||
|
||||
### PostgreSQL Database
|
||||
|
||||
Documenso uses PostgreSQL for all data storage including documents, users, and audit logs. You cannot use MySQL, SQLite, or other databases.
|
||||
|
||||
@@ -154,6 +154,34 @@ See [Background Jobs Configuration](/docs/self-hosting/configuration/background-
|
||||
|
||||
---
|
||||
|
||||
## IPv6-Only Deployments
|
||||
|
||||
If you are deploying to an environment that uses only IPv6, set the `HOST` environment variable to `::` so the application binds to all IPv6 addresses:
|
||||
|
||||
**Docker:**
|
||||
|
||||
```bash
|
||||
docker run -it -e HOST=:: documenso/documenso:latest npm run start
|
||||
```
|
||||
|
||||
**Kubernetes or Docker Compose:**
|
||||
|
||||
```yaml
|
||||
containers:
|
||||
- name: documenso
|
||||
image: documenso/documenso:latest
|
||||
command:
|
||||
- npm
|
||||
args:
|
||||
- run
|
||||
- start
|
||||
env:
|
||||
- name: HOST
|
||||
value: '::'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker File Permissions
|
||||
|
||||
The Documenso container runs as a non-root user (UID 1001). If you mount files into the container (certificates, configuration), ensure they're readable:
|
||||
|
||||
@@ -3,6 +3,8 @@ title: Self-Hosting
|
||||
description: Deploy and manage your own Documenso instance for complete control over your data, compliance, and customization.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
## Getting Started
|
||||
|
||||
<Cards>
|
||||
@@ -18,6 +20,13 @@ description: Deploy and manage your own Documenso instance for complete control
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
<Callout type="error">
|
||||
**You must generate a signing certificate.** Documenso does not ship with one. Without a
|
||||
certificate, the application starts normally but document signing will fail.
|
||||
|
||||
Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Deployment Options
|
||||
@@ -122,7 +131,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for
|
||||
|
||||
## Enterprise Edition
|
||||
|
||||
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed authoring white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
|
||||
Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key.
|
||||
|
||||
See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison.
|
||||
|
||||
|
||||
@@ -11,16 +11,41 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
| Limitation | Value |
|
||||
| ----------------------- | ----------------------------------- |
|
||||
| Supported format | PDF only |
|
||||
| Supported formats | PDF, DOCX |
|
||||
| Maximum file size | 50MB (configurable for self-hosted) |
|
||||
| Encrypted PDFs | Not supported |
|
||||
| Password-protected PDFs | Not supported |
|
||||
| Legacy `.doc` files | Not supported (convert to DOCX) |
|
||||
|
||||
<Callout type="warn">
|
||||
Documenso does not support password-protected or encrypted PDF files. Remove encryption before
|
||||
uploading.
|
||||
</Callout>
|
||||
|
||||
## Supported Formats
|
||||
|
||||
Documenso accepts two file formats:
|
||||
|
||||
- **PDF** (`.pdf`): used as-is. **Recommended.**
|
||||
- **Word** (`.docx`): converted to PDF on the server during upload. The converted PDF is what recipients sign.
|
||||
|
||||
Other formats (`.doc`, `.odt`, `.rtf`, images) are not supported. Convert them to PDF or DOCX before uploading.
|
||||
|
||||
<Callout type="warn">
|
||||
**Upload a PDF whenever you can.** DOCX files are converted to PDF using LibreOffice, which is not
|
||||
byte-identical to Microsoft Word. Spacing, line breaks, fonts, and complex elements (tables,
|
||||
charts, headers, footers) can shift in the converted PDF. For the final document to look exactly
|
||||
the way you designed it, export to PDF from Word, Google Docs, or Pages and upload the PDF
|
||||
directly.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
DOCX support requires the document conversion service. It is enabled on
|
||||
[documenso.com](https://app.documenso.com). Self-hosted instances must
|
||||
[configure it](/docs/self-hosting/configuration/advanced/document-conversion) before DOCX uploads
|
||||
are accepted.
|
||||
</Callout>
|
||||
|
||||
## Upload Methods
|
||||
|
||||

|
||||
@@ -38,15 +63,15 @@ You can upload documents in two ways:
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Drag and drop your PDF
|
||||
### Drag and drop your file
|
||||
|
||||
Drag a PDF file from your computer and drop it anywhere on the page.
|
||||
Drag a PDF or DOCX file from your computer and drop it anywhere on the page.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Wait for the upload to complete
|
||||
|
||||
The document will process and the editor will open when ready.
|
||||
The document will process and the editor will open when ready. DOCX files take a few extra seconds while they are converted to PDF.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -70,7 +95,7 @@ You can upload documents in two ways:
|
||||
<Step>
|
||||
### Select your file
|
||||
|
||||
Choose a PDF file from your computer.
|
||||
Choose a PDF or DOCX file from your computer.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
@@ -81,16 +106,32 @@ You can upload documents in two ways:
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## DOCX Conversion
|
||||
|
||||
We always recommend uploading a PDF rather than a DOCX. If you have the original document open in Word, Google Docs, or Pages, export to PDF from there and upload the PDF. The result is guaranteed to match what you see on screen.
|
||||
|
||||
If you do upload a `.docx` file, Documenso converts it to PDF before adding it to the envelope. The original `.docx` is discarded. Only the converted PDF is stored, signed, and downloaded.
|
||||
|
||||
Things to keep in mind when uploading DOCX:
|
||||
|
||||
- **The converted PDF will not be pixel-identical to your Word document.** Conversion uses LibreOffice, which renders most documents faithfully but differs from Microsoft Word in subtle ways. Spacing, font metrics, line breaks, and complex layout features can shift.
|
||||
- **Always review the converted PDF before adding fields or sending.** Open the document in the editor and scroll through every page to confirm it looks the way you expect.
|
||||
- **Form controls are flattened.** Word content controls (drop-downs, date pickers, checkboxes) become static text or graphics. Use Documenso fields for anything that needs to be filled in.
|
||||
- **Fonts not installed on the server fall back to substitutes.** On documenso.com, common fonts (Calibri, Arial, Times New Roman, etc.) are installed. On self-hosted instances, font fidelity depends on the operator's setup.
|
||||
- **Tracked changes and comments are preserved as they appear in Word.** Accept or reject changes and remove comments before uploading if you do not want them in the final document.
|
||||
|
||||
If the converted PDF does not match what you expect, export the document to PDF from Word, Google Docs, or another tool and upload the PDF directly.
|
||||
|
||||
## Uploading Multiple Documents
|
||||
|
||||
You can upload multiple PDF files at once to create a single envelope containing multiple documents. The number of files you can upload per envelope depends on your plan.
|
||||
You can upload multiple files at once to create a single envelope containing multiple documents. The number of files you can upload per envelope depends on your plan.
|
||||
|
||||
To upload multiple files:
|
||||
|
||||
- Select multiple PDF files when using the file picker, or
|
||||
- Drag and drop multiple PDF files at once
|
||||
- Select multiple PDF or DOCX files when using the file picker, or
|
||||
- Drag and drop multiple files at once
|
||||
|
||||
All files in the same upload become part of the same envelope and share the same recipients and signing workflow.
|
||||
You can mix PDF and DOCX files in the same upload. All files become part of the same envelope and share the same recipients and signing workflow.
|
||||
|
||||
<Callout type="info">
|
||||
If you need separate signing workflows for each document, upload them individually.
|
||||
@@ -114,15 +155,37 @@ The document remains in `Draft` status until you send it. You can close the edit
|
||||
<Accordion title="File is larger than 50MB">
|
||||
Reduce the file size before uploading:
|
||||
|
||||
- Compress images within the PDF
|
||||
- Compress images within the document
|
||||
- Remove unnecessary pages
|
||||
- Use a PDF compression tool
|
||||
- Use a PDF compression tool (for PDFs) or save with images downsampled (for DOCX)
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Only PDF files are allowed">
|
||||
Convert your document to PDF before uploading. Most applications (Word, Google Docs, etc.) can
|
||||
export to PDF format.
|
||||
<Accordion title="Only PDF and DOCX files are allowed">
|
||||
Documenso accepts PDF and DOCX. For other formats (`.doc`, `.odt`, `.rtf`, etc.), export to PDF
|
||||
from your editor (Word, Google Docs, Pages) and upload the PDF.
|
||||
|
||||
If you are self-hosted and DOCX is rejected, the [document conversion
|
||||
service](/docs/self-hosting/configuration/advanced/document-conversion) is not configured on your
|
||||
instance.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="DOCX upload fails with a conversion error">
|
||||
The document conversion service was reachable but could not convert the file. Common causes:
|
||||
|
||||
- The `.docx` file is corrupted. Open it in Word, save a new copy, and try again.
|
||||
- The file uses very unusual fonts or embedded objects that LibreOffice cannot render.
|
||||
- The file is unusually large or complex and exceeded the conversion timeout.
|
||||
|
||||
If the problem persists, export the document to PDF from Word and upload the PDF directly.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="DOCX upload fails with 'conversion service unavailable'">
|
||||
The document conversion service is down or temporarily unreachable. Try again in a minute. If you
|
||||
self-host, check the [document conversion
|
||||
service](/docs/self-hosting/configuration/advanced/document-conversion) logs.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="You cannot upload encrypted PDFs">
|
||||
|
||||
@@ -296,12 +296,27 @@ const config = {
|
||||
},
|
||||
{
|
||||
source: '/developers/embedding/authoring',
|
||||
destination: '/docs/developers/embedding/authoring',
|
||||
destination: '/docs/developers/embedding/editor',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/developers/embedding/authoring/:path*',
|
||||
destination: '/docs/developers/embedding/editor/:path*',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/developers/embedded-authoring',
|
||||
destination: '/docs/developers/embedding/authoring',
|
||||
destination: '/docs/developers/embedding/editor',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/docs/developers/embedding/authoring',
|
||||
destination: '/docs/developers/embedding/editor',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/docs/developers/embedding/authoring/:path*',
|
||||
destination: '/docs/developers/embedding/editor/:path*',
|
||||
permanent: true,
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type AdminOrganisationDeleteDialogProps = {
|
||||
organisationId: string;
|
||||
organisationName: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const AdminOrganisationDeleteDialog = ({
|
||||
organisationId,
|
||||
organisationName,
|
||||
trigger,
|
||||
}: AdminOrganisationDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const deleteMessage = t`delete ${organisationName}`;
|
||||
|
||||
const ZAdminDeleteOrganisationFormSchema = z.object({
|
||||
organisationName: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: t`You must enter '${deleteMessage}' to proceed` }),
|
||||
}),
|
||||
sendEmailToOwner: z.boolean(),
|
||||
});
|
||||
|
||||
type TAdminDeleteOrganisationFormSchema = z.infer<typeof ZAdminDeleteOrganisationFormSchema>;
|
||||
|
||||
const form = useForm<TAdminDeleteOrganisationFormSchema>({
|
||||
resolver: zodResolver(ZAdminDeleteOrganisationFormSchema),
|
||||
defaultValues: {
|
||||
organisationName: '',
|
||||
sendEmailToOwner: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteOrganisation } = trpc.admin.organisation.delete.useMutation();
|
||||
|
||||
const onFormSubmit = async (values: TAdminDeleteOrganisationFormSchema) => {
|
||||
try {
|
||||
await deleteOrganisation({
|
||||
organisationId,
|
||||
organisationName,
|
||||
sendEmailToOwner: values.sendEmailToOwner,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Deletion scheduled`,
|
||||
description: t`The organisation will be deleted in the background. Documents will be orphaned, not deleted.`,
|
||||
duration: 7500,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`An error occurred`,
|
||||
description: t`We encountered an error while attempting to delete this organisation. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Delete organisation</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are about to delete <span className="font-semibold">{organisationName}</span>. This action is not
|
||||
reversible. All teams will be removed and all documents will be orphaned to the deleted-account service
|
||||
account.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this
|
||||
deletion.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organisationName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing <span className="text-destructive">{deleteMessage}</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendEmailToOwner"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="admin-delete-organisation-send-email"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<label
|
||||
htmlFor="admin-delete-organisation-send-email"
|
||||
className="font-normal text-muted-foreground text-sm leading-snug"
|
||||
>
|
||||
<Trans>Email the organisation owner to notify them of the deletion.</Trans>
|
||||
</label>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -14,10 +14,10 @@ export const DateRangeFilter = ({ currentRange }: DateRangeFilterProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const handleRangeChange = (value: string) => {
|
||||
const handleRangeChange = (value: DateRange) => {
|
||||
startTransition(() => {
|
||||
updateSearchParams({
|
||||
dateRange: value as DateRange,
|
||||
dateRange: value,
|
||||
page: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -512,7 +512,7 @@ export function BrandingPreferencesForm({
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t`/* Write CSS targeting your signing pages. Selectors are scoped automatically. */
|
||||
placeholder={`/* Write CSS targeting your signing pages. Selectors are scoped automatically. */
|
||||
.my-button {
|
||||
background: red;
|
||||
}`}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Select, SelectContent, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
export type SearchParamSelector = {
|
||||
paramKey: string;
|
||||
isValueValid: (value: unknown) => boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SearchParamSelector = ({ children, paramKey, isValueValid }: SearchParamSelector) => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const value = useMemo(() => {
|
||||
const p = searchParams?.get(paramKey) ?? 'all';
|
||||
|
||||
return isValueValid(p) ? p : 'all';
|
||||
}, [searchParams]);
|
||||
|
||||
const onValueChange = (newValue: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set(paramKey, newValue);
|
||||
|
||||
if (newValue === '' || newValue === 'all') {
|
||||
params.delete(paramKey);
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select defaultValue={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="max-w-[200px] text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">{children}</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(initialValue);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term: string) => {
|
||||
const params = new URLSearchParams(searchParams?.toString() ?? '');
|
||||
if (term) {
|
||||
params.set('query', term);
|
||||
} else {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentQueryParam = searchParams.get('query') || '';
|
||||
|
||||
if (debouncedSearchTerm !== currentQueryParam) {
|
||||
handleSearch(debouncedSearchTerm);
|
||||
}
|
||||
}, [debouncedSearchTerm, searchParams]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search documents...`)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -140,6 +140,15 @@ export const DocumentUploadButtonLegacy = ({ className, type }: DocumentUploadBu
|
||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||
() => msg`You have reached the limit of the number of files per envelope.`,
|
||||
)
|
||||
.with('UNSUPPORTED_FILE_TYPE', () => msg`This file type isn't supported. Please upload a PDF or Word document.`)
|
||||
.with(
|
||||
'CONVERSION_SERVICE_UNAVAILABLE',
|
||||
() => msg`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
|
||||
)
|
||||
.with(
|
||||
'CONVERSION_FAILED',
|
||||
() => msg`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
|
||||
)
|
||||
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { getAllowedUploadMimeTypes } from '@documenso/lib/constants/document-conversion';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
@@ -115,6 +116,15 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
|
||||
.with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`)
|
||||
.with(
|
||||
'CONVERSION_SERVICE_UNAVAILABLE',
|
||||
() => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
|
||||
)
|
||||
.with(
|
||||
'CONVERSION_FAILED',
|
||||
() => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
|
||||
)
|
||||
.otherwise(() => t`An error occurred during upload.`);
|
||||
|
||||
toast({
|
||||
@@ -158,9 +168,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
});
|
||||
};
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
},
|
||||
accept: getAllowedUploadMimeTypes(),
|
||||
multiple: true,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
maxFiles: maximumEnvelopeItemCount,
|
||||
@@ -183,7 +191,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 text-md text-muted-foreground">
|
||||
<Trans>Drag and drop your PDF file here</Trans>
|
||||
<Trans>Drag and drop your document here</Trans>
|
||||
</p>
|
||||
|
||||
{isUploadDisabled && IS_BILLING_ENABLED() && (
|
||||
|
||||
@@ -119,6 +119,15 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.with('ENVELOPE_ITEM_LIMIT_EXCEEDED', () => t`You have reached the limit of the number of files per envelope.`)
|
||||
.with('UNSUPPORTED_FILE_TYPE', () => t`This file type isn't supported. Please upload a PDF or Word document.`)
|
||||
.with(
|
||||
'CONVERSION_SERVICE_UNAVAILABLE',
|
||||
() => t`Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.`,
|
||||
)
|
||||
.with(
|
||||
'CONVERSION_FAILED',
|
||||
() => t`We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.`,
|
||||
)
|
||||
.otherwise(() => t`An error occurred while uploading your document.`);
|
||||
|
||||
toast({
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return ['', '7d', '14d', '30d'].includes(value as string);
|
||||
};
|
||||
|
||||
export const PeriodSelector = () => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const period = useMemo(() => {
|
||||
const p = searchParams?.get('period') ?? 'all';
|
||||
|
||||
return isPeriodSelectorValue(p) ? p : 'all';
|
||||
}, [searchParams]);
|
||||
|
||||
const onPeriodChange = (newPeriod: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('period', newPeriod);
|
||||
|
||||
if (newPeriod === '' || newPeriod === 'all') {
|
||||
params.delete('period');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select defaultValue={period} onValueChange={onPeriodChange}>
|
||||
<SelectTrigger className="max-w-[200px] text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="all">
|
||||
<Trans>All Time</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="7d">
|
||||
<Trans>Last 7 days</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="14d">
|
||||
<Trans>Last 14 days</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value="30d">
|
||||
<Trans>Last 30 days</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { SelectItem } from '@documenso/ui/primitives/select';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
@@ -12,39 +11,33 @@ import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@prisma/client';
|
||||
import type { DocumentSource } from '@prisma/client';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SearchParamSelector } from '~/components/forms/search-param-selector';
|
||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
|
||||
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
|
||||
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
|
||||
import { DataTableTitle } from '~/components/tables/documents-table-title';
|
||||
import { TemplateDocumentsTableToolbar } from '~/components/tables/template-documents-table-toolbar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { PeriodSelector } from '../period-selector';
|
||||
|
||||
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
|
||||
DOCUMENT: msg`Document`,
|
||||
TEMPLATE: msg`Template`,
|
||||
TEMPLATE_DIRECT_LINK: msg`Direct link`,
|
||||
};
|
||||
|
||||
const ZDocumentSearchParamsSchema = ZUrlSearchParamsSchema.extend({
|
||||
source: z
|
||||
.nativeEnum(DocumentSource)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
status: z
|
||||
.nativeEnum(DocumentStatusEnum)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
const ZDocumentSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
page: true,
|
||||
perPage: true,
|
||||
query: true,
|
||||
period: true,
|
||||
status: true,
|
||||
source: true,
|
||||
});
|
||||
|
||||
type TemplatePageViewDocumentsTableProps = {
|
||||
@@ -59,9 +52,14 @@ export const TemplatePageViewDocumentsTable = ({ templateId }: TemplatePageViewD
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
const searchParamsString = searchParams.toString();
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.find.useQuery(
|
||||
const parsedSearchParams = useMemo(
|
||||
() => ZDocumentSearchParamsSchema.parse(Object.fromEntries(searchParams)),
|
||||
[searchParamsString],
|
||||
);
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
|
||||
{
|
||||
templateId,
|
||||
page: parsedSearchParams.page,
|
||||
@@ -69,6 +67,7 @@ export const TemplatePageViewDocumentsTable = ({ templateId }: TemplatePageViewD
|
||||
query: parsedSearchParams.query,
|
||||
source: parsedSearchParams.source,
|
||||
status: parsedSearchParams.status,
|
||||
period: parsedSearchParams.period,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
@@ -166,48 +165,11 @@ export const TemplatePageViewDocumentsTable = ({ templateId }: TemplatePageViewD
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
}, [_, i18n, team?.url]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-row space-x-4">
|
||||
<DocumentSearch />
|
||||
|
||||
<SearchParamSelector
|
||||
paramKey="status"
|
||||
isValueValid={(value) => [...DocumentStatusEnum.COMPLETED].includes(value as unknown as string)}
|
||||
>
|
||||
<SelectItem value="all">
|
||||
<Trans>Any Status</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentStatusEnum.COMPLETED}>
|
||||
<Trans>Completed</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentStatusEnum.PENDING}>
|
||||
<Trans>Pending</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentStatusEnum.DRAFT}>
|
||||
<Trans>Draft</Trans>
|
||||
</SelectItem>
|
||||
</SearchParamSelector>
|
||||
|
||||
<SearchParamSelector
|
||||
paramKey="source"
|
||||
isValueValid={(value) => [...DocumentSource.TEMPLATE].includes(value as unknown as string)}
|
||||
>
|
||||
<SelectItem value="all">
|
||||
<Trans>Any Source</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentSource.TEMPLATE}>
|
||||
<Trans>Template</Trans>
|
||||
</SelectItem>
|
||||
<SelectItem value={DocumentSource.TEMPLATE_DIRECT_LINK}>
|
||||
<Trans>Direct Link</Trans>
|
||||
</SelectItem>
|
||||
</SearchParamSelector>
|
||||
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<TemplateDocumentsTableToolbar />
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
type DocumentsTableSenderFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const DocumentsTableSenderFilter = ({ teamId }: DocumentsTableSenderFilterProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const senderIds = (searchParams?.get('senderIds') ?? '').split(',').filter((value) => value !== '');
|
||||
|
||||
const { data, isLoading } = trpc.team.member.getMany.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
|
||||
const onChange = (newSenderIds: string[]) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('senderIds', newSenderIds.join(','));
|
||||
|
||||
if (newSenderIds.length === 0) {
|
||||
params.delete('senderIds');
|
||||
}
|
||||
|
||||
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelectCombobox
|
||||
emptySelectionPlaceholder={
|
||||
<p className="font-normal text-muted-foreground">
|
||||
<Trans>
|
||||
<span className="text-muted-foreground/70">Sender:</span> All
|
||||
</Trans>
|
||||
</p>
|
||||
}
|
||||
enableClearAllButton={true}
|
||||
inputPlaceholder={msg`Search`}
|
||||
loading={!isMounted || isLoading}
|
||||
options={comboBoxOptions}
|
||||
selectedValues={senderIds}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { parseToStringArray, toCommaSeparatedSearchParam } from '@documenso/lib/utils/params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { DataTableFacetedFilter } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { PERIOD_OPTIONS } from './table-toolbar.constants';
|
||||
|
||||
type DocumentsTableToolbarProps = {
|
||||
teamId?: number;
|
||||
statusOptions: DataTableFacetedFilterOption[];
|
||||
statusCounts: TFindDocumentsInternalResponse['stats'];
|
||||
};
|
||||
|
||||
export const DocumentsTableToolbar = ({
|
||||
teamId,
|
||||
statusOptions,
|
||||
statusCounts,
|
||||
}: DocumentsTableToolbarProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const period = searchParams.get('period') ?? '';
|
||||
const statusParam = searchParams.get('status');
|
||||
const senderIdsParam = searchParams.get('senderIds');
|
||||
|
||||
const selectedStatusValues = useMemo(() => parseToStringArray(statusParam), [statusParam]);
|
||||
const selectedSenderValues = useMemo(() => parseToStringArray(senderIdsParam), [senderIdsParam]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm === query) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams(
|
||||
{ query: debouncedSearchTerm || undefined, page: undefined },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
const { data: members } = trpc.team.member.getMany.useQuery(
|
||||
{
|
||||
teamId: teamId ?? 0,
|
||||
},
|
||||
{
|
||||
enabled: teamId !== undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const senderOptions = useMemo(() => {
|
||||
return (members ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
}, [members]);
|
||||
|
||||
const periodOptions = useMemo<DataTableFacetedFilterOption[]>(() => {
|
||||
return PERIOD_OPTIONS.map((option) => ({
|
||||
label: _(option.label),
|
||||
value: option.value,
|
||||
}));
|
||||
}, [_]);
|
||||
|
||||
const hasActiveFilters =
|
||||
query.length > 0 ||
|
||||
selectedStatusValues.length > 0 ||
|
||||
selectedSenderValues.length > 0 ||
|
||||
(period.length > 0 && period !== 'all');
|
||||
|
||||
const onResetFilters = () => {
|
||||
setSearchTerm('');
|
||||
|
||||
updateSearchParams({
|
||||
query: undefined,
|
||||
status: undefined,
|
||||
senderIds: undefined,
|
||||
period: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[286px] max-w-[494px]">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={_(msg`Search documents...`)}
|
||||
className="h-9 w-full pe-9"
|
||||
/>
|
||||
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={_(msg`Clear search`)}
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Status`)}
|
||||
options={statusOptions}
|
||||
selectedValues={selectedStatusValues}
|
||||
counts={statusCounts}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
status: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{teamId !== undefined && (
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Sender`)}
|
||||
options={senderOptions}
|
||||
selectedValues={selectedSenderValues}
|
||||
showSearch
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
senderIds: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Time`)}
|
||||
options={periodOptions}
|
||||
selectedValues={period ? [period] : []}
|
||||
singleSelect
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
const nextPeriod = values[0];
|
||||
|
||||
updateSearchParams(
|
||||
{
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const PERIOD_OPTIONS: Array<{ label: MessageDescriptor; value: string }> = [
|
||||
{
|
||||
label: msg`All Time`,
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: msg`Last 7 days`,
|
||||
value: '7d',
|
||||
},
|
||||
{
|
||||
label: msg`Last 14 days`,
|
||||
value: '14d',
|
||||
},
|
||||
{
|
||||
label: msg`Last 30 days`,
|
||||
value: '30d',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@prisma/client';
|
||||
import { CheckCircle2, Clock, File, FileText, LinkIcon, XIcon } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { parseToStringArray, toCommaSeparatedSearchParam } from '@documenso/lib/utils/params';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { DataTableFacetedFilter } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { PERIOD_OPTIONS } from './table-toolbar.constants';
|
||||
|
||||
export const TemplateDocumentsTableToolbar = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams({ preventScrollReset: true });
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const period = searchParams.get('period') ?? '';
|
||||
const statusParam = searchParams.get('status');
|
||||
const sourceParam = searchParams.get('source');
|
||||
|
||||
const selectedStatusValues = useMemo(() => parseToStringArray(statusParam), [statusParam]);
|
||||
const selectedSourceValues = useMemo(() => parseToStringArray(sourceParam), [sourceParam]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm === query) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams(
|
||||
{ query: debouncedSearchTerm || undefined, page: undefined },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
const statusOptions = useMemo<DataTableFacetedFilterOption[]>(
|
||||
() => [
|
||||
{
|
||||
label: _(msg`Completed`),
|
||||
value: DocumentStatusEnum.COMPLETED,
|
||||
icon: CheckCircle2,
|
||||
iconClassName: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
{
|
||||
label: _(msg`Pending`),
|
||||
value: DocumentStatusEnum.PENDING,
|
||||
icon: Clock,
|
||||
iconClassName: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
{
|
||||
label: _(msg`Draft`),
|
||||
value: DocumentStatusEnum.DRAFT,
|
||||
icon: File,
|
||||
iconClassName: 'text-yellow-500 dark:text-yellow-200',
|
||||
},
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
const sourceOptions = useMemo<DataTableFacetedFilterOption[]>(
|
||||
() => [
|
||||
{
|
||||
label: _(msg`Template`),
|
||||
value: DocumentSource.TEMPLATE,
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
label: _(msg`Direct Link`),
|
||||
value: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||
icon: LinkIcon,
|
||||
},
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
const periodOptions = useMemo<DataTableFacetedFilterOption[]>(() => {
|
||||
return PERIOD_OPTIONS.map((option) => ({
|
||||
label: _(option.label),
|
||||
value: option.value,
|
||||
}));
|
||||
}, [_]);
|
||||
|
||||
const hasActiveFilters =
|
||||
query.length > 0 ||
|
||||
selectedStatusValues.length > 0 ||
|
||||
selectedSourceValues.length > 0 ||
|
||||
(period.length > 0 && period !== 'all');
|
||||
|
||||
const onResetFilters = () => {
|
||||
setSearchTerm('');
|
||||
|
||||
updateSearchParams({
|
||||
query: undefined,
|
||||
status: undefined,
|
||||
source: undefined,
|
||||
period: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[286px] max-w-[494px]">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={_(msg`Search documents...`)}
|
||||
className="h-9 w-full pe-9"
|
||||
/>
|
||||
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={_(msg`Clear search`)}
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Status`)}
|
||||
options={statusOptions}
|
||||
selectedValues={selectedStatusValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
status: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Source`)}
|
||||
options={sourceOptions}
|
||||
selectedValues={selectedSourceValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
source: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Time`)}
|
||||
options={periodOptions}
|
||||
selectedValues={period ? [period] : []}
|
||||
singleSelect
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
const nextPeriod = values[0];
|
||||
|
||||
updateSearchParams(
|
||||
{
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { TemplateType } from '@prisma/client';
|
||||
import { Globe2Icon, LockIcon, XIcon } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { parseToStringArray, toCommaSeparatedSearchParam } from '@documenso/lib/utils/params';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { DataTableFacetedFilter } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
export const TemplatesTableToolbar = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const typeParam = searchParams.get('type');
|
||||
|
||||
const selectedTypeValues = useMemo(() => parseToStringArray(typeParam), [typeParam]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(query);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm === query) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams(
|
||||
{ query: debouncedSearchTerm || undefined, page: undefined },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
const typeOptions = useMemo<DataTableFacetedFilterOption[]>(
|
||||
() => [
|
||||
{
|
||||
label: _(msg`Public`),
|
||||
value: TemplateType.PUBLIC,
|
||||
icon: Globe2Icon,
|
||||
iconClassName: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
{
|
||||
label: _(msg`Private`),
|
||||
value: TemplateType.PRIVATE,
|
||||
icon: LockIcon,
|
||||
iconClassName: 'text-blue-600 dark:text-blue-300',
|
||||
},
|
||||
],
|
||||
[_],
|
||||
);
|
||||
|
||||
const hasActiveFilters = query.length > 0 || selectedTypeValues.length > 0;
|
||||
|
||||
const onResetFilters = () => {
|
||||
setSearchTerm('');
|
||||
|
||||
updateSearchParams({
|
||||
query: undefined,
|
||||
type: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[286px] max-w-[494px]">
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={_(msg`Search templates...`)}
|
||||
className="h-9 w-full pe-9"
|
||||
/>
|
||||
|
||||
{searchTerm.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={_(msg`Clear search`)}
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTableFacetedFilter
|
||||
title={_(msg`Type`)}
|
||||
options={typeOptions}
|
||||
selectedValues={selectedTypeValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams(
|
||||
{
|
||||
type: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -37,6 +37,7 @@ import { Link, useNavigate } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organisation-delete-dialog';
|
||||
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
|
||||
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
@@ -64,9 +65,14 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
const organisationId = params.id;
|
||||
|
||||
const { data: organisation, isLoading: isLoadingOrganisation } = trpc.admin.organisation.get.useQuery({
|
||||
organisationId,
|
||||
});
|
||||
const { data: organisation, isLoading: isLoadingOrganisation } = trpc.admin.organisation.get.useQuery(
|
||||
{
|
||||
organisationId,
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: createStripeCustomer, isPending: isCreatingStripeCustomer } =
|
||||
trpc.admin.stripe.createCustomer.useMutation({
|
||||
@@ -398,6 +404,31 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsHeader
|
||||
title={t`Danger Zone`}
|
||||
subtitle={t`Irreversible actions for this organisation`}
|
||||
className="mt-16"
|
||||
/>
|
||||
|
||||
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="destructive">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Delete organisation</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible
|
||||
via the deleted-account service account.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<AdminOrganisationDeleteDialog organisationId={organisation.id} organisationName={organisation.name} />
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
|
||||
import { SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
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 type { DataTableFacetedFilterOption } from '@documenso/ui/primitives/data-table-faceted-filter';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams, useSearchParams } from 'react-router';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
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 { FRIENDLY_STATUS_MAP } from '~/components/general/document/document-status';
|
||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
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 { DocumentsTableToolbar } from '~/components/tables/documents-table-toolbar';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@@ -49,6 +46,8 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||
});
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
@@ -90,35 +89,36 @@ export default function DocumentsPage() {
|
||||
},
|
||||
);
|
||||
|
||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const statusOptions = useMemo<DataTableFacetedFilterOption[]>(() => {
|
||||
return [
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.REJECTED,
|
||||
]
|
||||
.filter((status) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return status !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
params.set('status', value);
|
||||
return true;
|
||||
})
|
||||
.map((status) => {
|
||||
const { label, icon, color } = FRIENDLY_STATUS_MAP[status];
|
||||
|
||||
if (value === ExtendedDocumentStatus.ALL) {
|
||||
params.delete('status');
|
||||
}
|
||||
return {
|
||||
label: _(label),
|
||||
value: status,
|
||||
icon,
|
||||
iconClassName: color,
|
||||
};
|
||||
});
|
||||
}, [organisation.type, _]);
|
||||
|
||||
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
|
||||
params.delete('status');
|
||||
}
|
||||
const selectedStatuses = findDocumentSearchParams.status ?? [];
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
let path = formatDocumentsPath(team.url);
|
||||
|
||||
if (folderId) {
|
||||
path += `/f/${folderId}`;
|
||||
}
|
||||
|
||||
if (params.toString()) {
|
||||
path += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
const selectedStatus = selectedStatuses.length === 1 ? selectedStatuses[0] : ExtendedDocumentStatus.ALL;
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.stats) {
|
||||
@@ -142,55 +142,16 @@ export default function DocumentsPage() {
|
||||
<Trans>Documents</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
]
|
||||
.filter((value) => {
|
||||
if (organisation.type === OrganisationType.PERSONAL) {
|
||||
return value !== ExtendedDocumentStatus.INBOX;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((value) => (
|
||||
<TabsTrigger key={value} className="min-w-[60px] hover:text-foreground" value={value} asChild>
|
||||
<Link to={getTabHref(value)} preventScrollReset>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">
|
||||
{stats[value] >= STATS_COUNT_CAP ? `${STATS_COUNT_CAP.toLocaleString()}+` : stats[value]}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<DocumentsTableToolbar teamId={team?.id} statusOptions={statusOptions} statusCounts={stats} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
{data && data.count === 0 ? (
|
||||
<DocumentsTableEmptyState status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL} />
|
||||
<DocumentsTableEmptyState status={selectedStatus} />
|
||||
) : (
|
||||
<DocumentsTable
|
||||
data={data}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { parseToStringArray } from '@documenso/lib/utils/params';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
@@ -9,6 +11,7 @@ import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TemplateType } from '@prisma/client';
|
||||
import { EnvelopeType, OrganisationType } from '@prisma/client';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
@@ -21,6 +24,7 @@ import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-
|
||||
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 { TemplatesTableToolbar } from '~/components/tables/templates-table-toolbar';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@@ -32,6 +36,12 @@ export function meta() {
|
||||
return appMetaTags(msg`Templates`);
|
||||
}
|
||||
|
||||
const ZTemplatesSearchParamsSchema = ZFindSearchParamsSchema.pick({
|
||||
query: true,
|
||||
page: true,
|
||||
perPage: true,
|
||||
});
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
@@ -39,8 +49,15 @@ export default function TemplatesPage() {
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
const findTemplatesSearchParams = useMemo(
|
||||
() => ZTemplatesSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const typeFilter = useMemo(() => {
|
||||
const selected = parseToStringArray(searchParams.get('type'));
|
||||
return selected.length === 1 ? (selected[0] as TemplateType) : undefined;
|
||||
}, [searchParams]);
|
||||
|
||||
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
|
||||
|
||||
@@ -60,8 +77,8 @@ export default function TemplatesPage() {
|
||||
|
||||
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
...findTemplatesSearchParams,
|
||||
type: typeFilter,
|
||||
folderId,
|
||||
},
|
||||
{
|
||||
@@ -71,8 +88,8 @@ export default function TemplatesPage() {
|
||||
|
||||
const orgTemplatesQuery = trpc.template.findOrganisationTemplates.useQuery(
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
page: findTemplatesSearchParams.page,
|
||||
perPage: findTemplatesSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
enabled: isOrgView,
|
||||
@@ -129,6 +146,12 @@ export default function TemplatesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOrgView && (
|
||||
<div className="mt-8">
|
||||
<TemplatesTableToolbar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
{activeQuery.data && activeQuery.data.count === 0 ? (
|
||||
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
|
||||
@@ -106,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.10.1"
|
||||
"version": "2.11.0"
|
||||
}
|
||||
|
||||
+5
-258
@@ -1,261 +1,8 @@
|
||||
# Docker Setup for Documenso
|
||||
|
||||
The following guide will walk you through setting up Documenso using Docker. You can choose between a production setup using Docker Compose or a standalone container.
|
||||
For full instructions on running Documenso with Docker, see the official documentation:
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure that you have the following installed:
|
||||
|
||||
- Docker
|
||||
- Docker Compose (if using the Docker Compose setup)
|
||||
|
||||
## Option 1: Production Docker Compose Setup
|
||||
|
||||
This setup includes a PostgreSQL database and the Documenso application. You will need to provide your own SMTP details via environment variables.
|
||||
|
||||
1. Download the Docker Compose file from the Documenso repository: [compose.yml](https://raw.githubusercontent.com/documenso/documenso/release/docker/production/compose.yml)
|
||||
2. Navigate to the directory containing the `compose.yml` file.
|
||||
3. Create a `.env` file in the same directory and add your SMTP details as well as a few extra environment variables, following the example below:
|
||||
|
||||
```
|
||||
# Generate random secrets (you can use: openssl rand -hex 32)
|
||||
NEXTAUTH_SECRET="<your-secret>"
|
||||
NEXT_PRIVATE_ENCRYPTION_KEY="<your-key>"
|
||||
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-secondary-key>"
|
||||
|
||||
# Your application URL
|
||||
NEXT_PUBLIC_WEBAPP_URL="<your-url>"
|
||||
|
||||
# SMTP Configuration
|
||||
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
||||
NEXT_PRIVATE_SMTP_HOST="<your-host>"
|
||||
NEXT_PRIVATE_SMTP_PORT=<your-port>
|
||||
NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
|
||||
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME="<your-from-name>"
|
||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-from-email>"
|
||||
|
||||
# Certificate passphrase (required)
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>"
|
||||
```
|
||||
|
||||
4. Set up your signing certificate. You have three options:
|
||||
|
||||
**Option A: Generate Certificate Inside Container (Recommended)**
|
||||
|
||||
Start your containers first, then generate a self-signed certificate:
|
||||
|
||||
```bash
|
||||
# Start containers
|
||||
docker-compose up -d
|
||||
|
||||
# Set certificate password securely (won't appear in command history)
|
||||
read -s -p "Enter certificate password: " CERT_PASS
|
||||
echo
|
||||
|
||||
# Generate certificate inside container using environment variable
|
||||
docker exec -e CERT_PASS="$CERT_PASS" -it documenso-production-documenso-1 bash -c "
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout /tmp/private.key \
|
||||
-out /tmp/certificate.crt \
|
||||
-subj '/C=US/ST=State/L=City/O=Organization/CN=localhost' && \
|
||||
openssl pkcs12 -export -out /app/certs/cert.p12 \
|
||||
-inkey /tmp/private.key -in /tmp/certificate.crt \
|
||||
-passout env:CERT_PASS && \
|
||||
rm /tmp/private.key /tmp/certificate.crt
|
||||
"
|
||||
|
||||
# Restart container
|
||||
docker-compose restart documenso
|
||||
```
|
||||
|
||||
**Option B: Use Existing Certificate**
|
||||
|
||||
If you have an existing `.p12` certificate, update the volume binding in `compose.yml`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/your/cert.p12:/opt/documenso/cert.p12:ro
|
||||
```
|
||||
|
||||
5. Run the following command to start the containers:
|
||||
|
||||
```
|
||||
docker-compose --env-file ./.env up -d
|
||||
```
|
||||
|
||||
This will start the PostgreSQL database and the Documenso application containers.
|
||||
|
||||
6. Access the Documenso application by visiting `http://localhost:3000` in your web browser.
|
||||
|
||||
## Option 2: Standalone Docker Container
|
||||
|
||||
If you prefer to host the Documenso application on your container provider of choice, you can use the pre-built Docker image from DockerHub or GitHub's Package Registry. Note that you will need to provide your own database and SMTP host.
|
||||
|
||||
1. Pull the Documenso Docker image:
|
||||
|
||||
```
|
||||
docker pull documenso/documenso
|
||||
```
|
||||
|
||||
Or, if using GitHub's Package Registry:
|
||||
|
||||
```
|
||||
docker pull ghcr.io/documenso/documenso
|
||||
```
|
||||
|
||||
2. Run the Docker container, providing the necessary environment variables for your database and SMTP host:
|
||||
|
||||
```
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e NEXTAUTH_SECRET="<your-nextauth-secret>" \
|
||||
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>" \
|
||||
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>" \
|
||||
-e NEXT_PUBLIC_WEBAPP_URL="<your-next-public-webapp-url>" \
|
||||
-e NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000" \
|
||||
-e NEXT_PRIVATE_DATABASE_URL="<your-next-private-database-url>" \
|
||||
-e NEXT_PRIVATE_DIRECT_DATABASE_URL="<your-next-private-database-url>" \
|
||||
-e NEXT_PRIVATE_SMTP_TRANSPORT="<your-next-private-smtp-transport>" \
|
||||
-e NEXT_PRIVATE_SMTP_FROM_NAME="<your-next-private-smtp-from-name>" \
|
||||
-e NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-next-private-smtp-from-address>" \
|
||||
-e NEXT_PRIVATE_SIGNING_PASSPHRASE="<your-certificate-password>" \
|
||||
-v /path/to/your/cert.p12:/opt/documenso/cert.p12:ro \
|
||||
documenso/documenso
|
||||
```
|
||||
|
||||
Replace the placeholders with your actual database and SMTP details.
|
||||
|
||||
3. Access the Documenso application by visiting the URL you provided in the `NEXT_PUBLIC_WEBAPP_URL` environment variable in your web browser.
|
||||
|
||||
## Success
|
||||
|
||||
You have now successfully set up Documenso using Docker. You can start organizing and managing your documents efficiently.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate Permission Issues
|
||||
|
||||
If you encounter errors related to certificate access, here are common solutions:
|
||||
|
||||
#### Error: "Failed to read signing certificate"
|
||||
|
||||
1. **Check file exists:**
|
||||
|
||||
```bash
|
||||
ls -la /path/to/your/cert.p12
|
||||
```
|
||||
|
||||
2. **Fix permissions:**
|
||||
|
||||
```bash
|
||||
chmod 644 /path/to/your/cert.p12
|
||||
chown 1001:1001 /path/to/your/cert.p12
|
||||
```
|
||||
|
||||
3. **Verify Docker mount:**
|
||||
```bash
|
||||
docker exec -it <container_name> ls -la /opt/documenso/cert.p12
|
||||
```
|
||||
|
||||
### Container Logs
|
||||
|
||||
Check application logs for detailed error information:
|
||||
|
||||
```bash
|
||||
# For Docker Compose
|
||||
docker-compose logs -f documenso
|
||||
|
||||
# For standalone container
|
||||
docker logs -f <container_name>
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
Check the status of your Documenso instance:
|
||||
|
||||
```bash
|
||||
# Basic health check (database + certificate)
|
||||
curl http://localhost:3000/api/health
|
||||
|
||||
# Detailed certificate status
|
||||
curl http://localhost:3000/api/certificate-status
|
||||
```
|
||||
|
||||
The health endpoint will show:
|
||||
|
||||
- `status: "ok"` - Everything working properly
|
||||
- `status: "warning"` - App running but certificate issues
|
||||
- `status: "error"` - Critical issues (database down, etc.)
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Port already in use:** Change the port mapping in compose.yml or your docker run command
|
||||
2. **Database connection issues:** Ensure your database is running and accessible
|
||||
3. **SMTP errors:** Verify your email server settings in the .env file
|
||||
|
||||
If you encounter any issues or have further questions, please refer to the official Documenso documentation or seek assistance from the community.
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
The environment variables listed above are a subset of those that are available for configuring Documenso. For a complete list of environment variables and their descriptions, refer to the table below:
|
||||
|
||||
Here's a markdown table documenting all the provided environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | The port to run the Documenso application on, defaults to `3000`. |
|
||||
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
|
||||
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
|
||||
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
|
||||
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
|
||||
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
|
||||
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default), gcloud-hsm |
|
||||
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file, will be used instead of file path. |
|
||||
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH` | The Google Cloud HSM key path for the gcloud-hsm signing transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH` | The path to the Google Cloud HSM public certificate file for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS` | The base64-encoded Google Cloud HSM public certificate for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS` | The base64-encoded Google Cloud Credentials for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_FILE_PATH` | The path to the certificate chain file for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_CERT_CHAIN_CONTENTS` | The base64-encoded certificate chain for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_GCLOUD_HSM_SECRET_MANAGER_CERT_PATH` | The Google Secret Manager path to retrieve the certificate for the gcloud-hsm transport. |
|
||||
| `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` | Comma-separated list of timestamp authority URLs for PDF signing (enables LTV). |
|
||||
| `NEXT_PUBLIC_SIGNING_CONTACT_INFO` | Contact info to embed in PDF signatures. Defaults to the webapp URL. |
|
||||
| `NEXT_PRIVATE_USE_LEGACY_SIGNING_SUBFILTER` | Set to "true" to use legacy adbe.pkcs7.detached subfilter instead of ETSI.CAdES.detached. |
|
||||
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
|
||||
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
|
||||
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
|
||||
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
|
||||
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to use for sending emails (smtp-auth, smtp-api, resend, or mailchannels). |
|
||||
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
|
||||
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
|
||||
| `NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS` | If true, then no TLS will be used (even if STARTTLS is supported) |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
|
||||
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
|
||||
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
|
||||
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
|
||||
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Master switch. Set to `true` to disable all signup methods (incl. organisation OIDC portal). |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNUP` | Set to `true` to disable email/password signup only. SSO signup is unaffected. |
|
||||
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNUP` | Set to `true` to block new accounts via Google. Existing Google-linked users can still sign in. |
|
||||
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Set to `true` to block new accounts via Microsoft. Existing linked users can still sign in. |
|
||||
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Set to `true` to block new accounts via OIDC (incl. organisation portal). Existing users unaffected.|
|
||||
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`). |
|
||||
- [Docker Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker) — Standalone container with an external database
|
||||
- [Docker Compose Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker-compose) — Production setup with PostgreSQL included
|
||||
- [Environment Variables](https://docs.documenso.com/docs/self-hosting/configuration/environment) — Full configuration reference
|
||||
- [Signing Certificate](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate) — Set up document signing
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
FROM gotenberg/gotenberg:8-libreoffice
|
||||
|
||||
# Install Microsoft Core Fonts (Arial, Times New Roman, Courier New, Verdana,
|
||||
# Georgia, Comic Sans MS, Trebuchet MS, Impact, Andale Mono, Webdings) so that
|
||||
# LibreOffice can render typical Word documents faithfully. The default image
|
||||
# only ships metric-compatible substitutes (Carlito for Calibri, Liberation for
|
||||
# Arial/Times/Courier) which preserve layout widths but look noticeably wrong.
|
||||
#
|
||||
# `ttf-mscorefonts-installer` lives in the non-free repo and requires accepting
|
||||
# the Microsoft EULA, which we do non-interactively via debconf-set-selections.
|
||||
USER root
|
||||
|
||||
RUN echo "deb http://deb.debian.org/debian trixie contrib non-free" \
|
||||
> /etc/apt/sources.list.d/contrib.list \
|
||||
&& echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" \
|
||||
| debconf-set-selections \
|
||||
&& apt-get update -qq \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates \
|
||||
wget \
|
||||
unzip \
|
||||
culmus \
|
||||
ttf-mscorefonts-installer \
|
||||
fonts-symbola \
|
||||
fonts-noto-extra \
|
||||
fonts-hosny-amiri \
|
||||
fonts-thai-tlwg \
|
||||
fonts-sil-padauk \
|
||||
fonts-sarai \
|
||||
fonts-samyak-taml \
|
||||
libfribidi0 \
|
||||
libharfbuzz0b \
|
||||
&& fc-cache -f \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
USER gotenberg
|
||||
@@ -48,6 +48,52 @@ services:
|
||||
entrypoint: sh
|
||||
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
|
||||
|
||||
gotenberg:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.gotenberg
|
||||
image: documenso-dev-gotenberg:latest
|
||||
container_name: gotenberg
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3005:3000
|
||||
environment:
|
||||
# Basic auth credentials Gotenberg checks when `--api-enable-basic-auth`
|
||||
# is passed. Dev defaults are non-secret — match
|
||||
# `NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME` / `_PASSWORD` in `.env`.
|
||||
GOTENBERG_API_BASIC_AUTH_USERNAME: documenso
|
||||
GOTENBERG_API_BASIC_AUTH_PASSWORD: password
|
||||
command:
|
||||
- gotenberg
|
||||
# Require basic auth on every API route — prevents anyone with network
|
||||
# access to the container from invoking conversions.
|
||||
- --api-enable-basic-auth
|
||||
# SSRF defence in depth: reject any outbound fetch LibreOffice tries to
|
||||
# make to a private/loopback/link-local/cloud-metadata address while
|
||||
# processing an uploaded document. Mitigates CVE-2026-42591 (malicious
|
||||
# docx files embedding `TargetMode="External"` references to internal
|
||||
# services). Added in Gotenberg 8.32.0.
|
||||
- --libreoffice-deny-private-ips
|
||||
# Generous server-side timeout; the Node client aborts at 30 s by
|
||||
# default, so this is just a safety net.
|
||||
- --api-timeout=500s
|
||||
# Pre-warm LibreOffice at boot so the first request isn't cold.
|
||||
- --libreoffice-auto-start
|
||||
- --libreoffice-start-timeout=300s
|
||||
# Disable surfaces we don't use to shrink the attack surface.
|
||||
- --pdfengines-disable-routes
|
||||
- --webhook-disable
|
||||
# Verbose logs for the dev compose only.
|
||||
- --log-level=debug
|
||||
healthcheck:
|
||||
# `/health` is exempt from `--api-enable-basic-auth` so the check
|
||||
# doesn't need to authenticate.
|
||||
test: ['CMD', 'curl', '-fsS', 'http://localhost:3000/health']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
minio:
|
||||
redis:
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.10.1",
|
||||
"version": "2.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.10.1",
|
||||
"version": "2.11.0",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -406,7 +406,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.10.1",
|
||||
"version": "2.11.0",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.10.1",
|
||||
"version": "2.11.0",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { BackgroundJobStatus, DocumentStatus, EnvelopeType, Role } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin, apiSignout } from '../../fixtures/authentication';
|
||||
|
||||
/**
|
||||
* Helper that polls until the `admin.organisation.delete` background job for the
|
||||
* supplied organisation has finished (status COMPLETED). Returns the org id.
|
||||
*/
|
||||
const waitForOrganisationDeletionJob = async (organisationId: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
// payload is JSON; match the organisationId field.
|
||||
payload: {
|
||||
path: ['organisationId'],
|
||||
equals: organisationId,
|
||||
},
|
||||
},
|
||||
orderBy: { submittedAt: 'desc' },
|
||||
});
|
||||
|
||||
return job?.status ?? null;
|
||||
},
|
||||
{
|
||||
message: `Background deletion job for organisation ${organisationId} did not complete in time`,
|
||||
timeout: 30_000,
|
||||
intervals: [250, 500, 1000],
|
||||
},
|
||||
)
|
||||
.toBe(BackgroundJobStatus.COMPLETED);
|
||||
};
|
||||
|
||||
const waitForOrganisationToBeGone = async (organisationId: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const org = await prisma.organisation.findUnique({
|
||||
where: { id: organisationId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return org === null;
|
||||
},
|
||||
{
|
||||
message: `Organisation ${organisationId} was not removed`,
|
||||
timeout: 30_000,
|
||||
intervals: [250, 500, 1000],
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
// ─── Happy path ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: admin can delete an organisation via the dialog', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Danger Zone' })).toBeVisible();
|
||||
|
||||
// Open the dialog
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// The Delete submit button is initially enabled but submission should fail
|
||||
// until the confirmation text matches. Type it now.
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
|
||||
// The "send email to owner" checkbox should be checked by default.
|
||||
const emailCheckbox = dialog.getByRole('checkbox');
|
||||
await expect(emailCheckbox).toBeChecked();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
|
||||
// Dialog closes on success
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Background job completes and the org is removed
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
});
|
||||
|
||||
// ─── Confirmation text validation ────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: typing the wrong confirmation text prevents deletion', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Type something that does NOT match.
|
||||
await dialog.getByRole('textbox').fill('delete wrong-name');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
|
||||
// Validation message should appear and the dialog should stay open.
|
||||
await expect(dialog.getByText(/You must enter/)).toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Org is still there.
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: empty confirmation text prevents deletion', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
|
||||
await expect(dialog.getByText(/You must enter/)).toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Cancel ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: clicking Cancel closes the dialog without deleting', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Fill in the correct text but cancel anyway.
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// Org still there.
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Email checkbox ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: email checkbox can be unchecked, payload reflects choice', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const emailCheckbox = dialog.getByRole('checkbox');
|
||||
|
||||
// Default is checked.
|
||||
await expect(emailCheckbox).toBeChecked();
|
||||
|
||||
// Uncheck it.
|
||||
await emailCheckbox.click();
|
||||
await expect(emailCheckbox).not.toBeChecked();
|
||||
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Verify the enqueued job payload has sendEmailToOwner=false.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = job.payload as { sendEmailToOwner?: boolean };
|
||||
return payload.sendEmailToOwner;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(false);
|
||||
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
});
|
||||
|
||||
// ─── Documents are orphaned, not deleted ─────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: envelopes authored by owner and members are orphaned, drafts are removed', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
// Add two organisation members who will author their own envelopes.
|
||||
const [memberUser, managerUser] = await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [{ organisationRole: 'MEMBER' }, { organisationRole: 'MANAGER' }],
|
||||
});
|
||||
|
||||
// ── Owner-authored envelopes ──────────────────────────────────────────────
|
||||
const ownerCompleted = await seedBlankDocument(owner, team.id, { key: 'owner-completed' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: ownerCompleted.id },
|
||||
data: { status: DocumentStatus.COMPLETED },
|
||||
});
|
||||
|
||||
const ownerPending = await seedBlankDocument(owner, team.id, { key: 'owner-pending' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: ownerPending.id },
|
||||
data: { status: DocumentStatus.PENDING },
|
||||
});
|
||||
|
||||
const ownerDraft = await seedBlankDocument(owner, team.id, { key: 'owner-draft' });
|
||||
|
||||
// ── Member-authored envelopes ─────────────────────────────────────────────
|
||||
const memberCompleted = await seedBlankDocument(memberUser, team.id, { key: 'member-completed' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: memberCompleted.id },
|
||||
data: { status: DocumentStatus.COMPLETED },
|
||||
});
|
||||
|
||||
const memberPending = await seedBlankDocument(memberUser, team.id, { key: 'member-pending' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: memberPending.id },
|
||||
data: { status: DocumentStatus.PENDING },
|
||||
});
|
||||
|
||||
const memberDraft = await seedBlankDocument(memberUser, team.id, { key: 'member-draft' });
|
||||
|
||||
// ── Manager-authored envelope (third author for good measure) ─────────────
|
||||
const managerRejected = await seedBlankDocument(managerUser, team.id, { key: 'manager-rejected' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: managerRejected.id },
|
||||
data: { status: DocumentStatus.REJECTED },
|
||||
});
|
||||
|
||||
// Sanity check: before deletion all 7 envelopes belong to the team and
|
||||
// retain their original authors.
|
||||
const beforeCount = await prisma.envelope.count({ where: { teamId: team.id } });
|
||||
expect(beforeCount).toBe(7);
|
||||
|
||||
// ── Trigger the deletion via the admin UI ─────────────────────────────────
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// The deleted-account service account is where orphaned envelopes land.
|
||||
const deletedAccount = await prisma.user.findFirstOrThrow({
|
||||
where: { email: { startsWith: 'deleted-account@' } },
|
||||
select: { id: true, ownedOrganisations: { select: { teams: { select: { id: true } } } } },
|
||||
});
|
||||
const deletedAccountTeamId = deletedAccount.ownedOrganisations[0].teams[0].id;
|
||||
|
||||
// ── Owner-authored envelopes ──────────────────────────────────────────────
|
||||
// Completed/pending: orphaned (reparented to service account + deletedAt set).
|
||||
for (const original of [ownerCompleted, ownerPending]) {
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: original.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(after, `owner envelope ${original.id} should survive as orphan`).not.toBeNull();
|
||||
expect(after?.teamId).toBe(deletedAccountTeamId);
|
||||
expect(after?.userId).toBe(deletedAccount.id);
|
||||
expect(after?.deletedAt).not.toBeNull();
|
||||
}
|
||||
|
||||
// Draft: hard-deleted because orphan only re-parents PENDING/REJECTED/COMPLETED.
|
||||
const ownerDraftAfter = await prisma.envelope.findUnique({
|
||||
where: { id: ownerDraft.id },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(ownerDraftAfter, 'owner draft should be hard-deleted').toBeNull();
|
||||
|
||||
// ── Member-authored envelopes (the critical case) ─────────────────────────
|
||||
// The orphan logic filters by teamId only — NOT by userId — so member-authored
|
||||
// envelopes must be orphaned just like the owner's.
|
||||
for (const original of [memberCompleted, memberPending]) {
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: original.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(after, `member envelope ${original.id} should survive as orphan`).not.toBeNull();
|
||||
expect(after?.teamId).toBe(deletedAccountTeamId);
|
||||
expect(after?.userId).toBe(deletedAccount.id);
|
||||
expect(after?.deletedAt).not.toBeNull();
|
||||
}
|
||||
|
||||
const memberDraftAfter = await prisma.envelope.findUnique({
|
||||
where: { id: memberDraft.id },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(memberDraftAfter, 'member draft should be hard-deleted').toBeNull();
|
||||
|
||||
// ── Manager-authored rejected envelope: also orphaned ─────────────────────
|
||||
const managerRejectedAfter = await prisma.envelope.findUnique({
|
||||
where: { id: managerRejected.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(managerRejectedAfter).not.toBeNull();
|
||||
expect(managerRejectedAfter?.teamId).toBe(deletedAccountTeamId);
|
||||
expect(managerRejectedAfter?.userId).toBe(deletedAccount.id);
|
||||
|
||||
// ── Original team is gone, member users still exist ───────────────────────
|
||||
const teamAfter = await prisma.team.findUnique({ where: { id: team.id } });
|
||||
expect(teamAfter).toBeNull();
|
||||
|
||||
// No envelope should reference the now-deleted team.
|
||||
const orphanedToOldTeam = await prisma.envelope.count({ where: { teamId: team.id } });
|
||||
expect(orphanedToOldTeam).toBe(0);
|
||||
|
||||
// The owner and members survive — only the org is deleted, not the users.
|
||||
const ownerAfter = await prisma.user.findUnique({ where: { id: owner.id } });
|
||||
const memberAfter = await prisma.user.findUnique({ where: { id: memberUser.id } });
|
||||
const managerAfter = await prisma.user.findUnique({ where: { id: managerUser.id } });
|
||||
expect(ownerAfter).not.toBeNull();
|
||||
expect(memberAfter).not.toBeNull();
|
||||
expect(managerAfter).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Owner can no longer access the deleted organisation ─────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: the original owner loses access after deletion', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// Sign in as the original owner and confirm they can no longer reach the
|
||||
// organisation settings page.
|
||||
await apiSignout({ page });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
// They should NOT see the organisation settings heading for this org.
|
||||
await expect(page.getByText('Organisation Settings')).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Access control: UI ──────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: non-admin user cannot access /admin/organisations/$id', async ({ page }) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: nonAdminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
// The admin layout loader redirects non-admins to "/". They must not see the
|
||||
// admin panel or any Delete affordance.
|
||||
await expect(page.getByRole('heading', { name: 'Admin Panel' })).not.toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).not.toBeVisible();
|
||||
|
||||
// The org must still exist.
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: unauthenticated user cannot access /admin/organisations/$id', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
// No apiSignin call. Navigate directly.
|
||||
await page.goto(`/admin/organisations/${organisation.id}`);
|
||||
|
||||
// Unauthenticated requests should be redirected away from any /admin/* route.
|
||||
await expect(page).not.toHaveURL(new RegExp(`/admin/organisations/${organisation.id}`));
|
||||
await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible();
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Belt-and-braces: organisation owner (without admin role) can't use it ──
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: an organisation owner without admin role cannot reach the admin delete UI', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
// Confirm the owner is NOT an admin (sanity check on the seed).
|
||||
expect(owner.roles).not.toContain(Role.ADMIN);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).not.toBeVisible();
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Org with multiple members triggers email to the OWNER only ─────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: job payload targets the organisation owner for the email notification', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [{ organisationRole: 'MEMBER' }, { organisationRole: 'ADMIN' }, { organisationRole: 'MANAGER' }],
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The job payload should record the admin who requested the delete and
|
||||
// sendEmailToOwner=true. (Verifying the actual email send is out of scope
|
||||
// for this test; we verify the payload only.)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = job.payload as {
|
||||
sendEmailToOwner?: boolean;
|
||||
requestedByUserId?: number;
|
||||
};
|
||||
|
||||
return payload;
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toMatchObject({
|
||||
sendEmailToOwner: true,
|
||||
requestedByUserId: adminUser.id,
|
||||
});
|
||||
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// Owner user record itself is NOT deleted — only the org.
|
||||
const ownerStillExists = await prisma.user.findUnique({ where: { id: owner.id } });
|
||||
expect(ownerStillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── EnvelopeType.TEMPLATE is also cleaned up via orphan flow ───────────────
|
||||
|
||||
test('[ADMIN][DELETE_ORG]: template envelopes are removed (not orphaned)', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
// Create a TEMPLATE envelope. orphanEnvelopes only re-parents DOCUMENT
|
||||
// envelopes; templates fall into the "deleteMany" path.
|
||||
const draftDoc = await seedBlankDocument(owner, team.id, { key: 'tmpl' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: draftDoc.id },
|
||||
data: { type: EnvelopeType.TEMPLATE },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).first().click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('textbox').fill(`delete ${organisation.name}`);
|
||||
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await waitForOrganisationDeletionJob(organisation.id);
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
const templateAfter = await prisma.envelope.findUnique({
|
||||
where: { id: draftDoc.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
expect(templateAfter).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const callDeleteOrganisation = async (
|
||||
page: Page,
|
||||
input: {
|
||||
organisationId: string;
|
||||
organisationName: string;
|
||||
sendEmailToOwner: boolean;
|
||||
},
|
||||
) => {
|
||||
return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/admin.organisation.delete`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Access control ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: unauthenticated request is rejected with 401', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
// No sign-in.
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: true,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
|
||||
// Org must still exist.
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
|
||||
// No deletion job must have been enqueued.
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
expect(job).toBeNull();
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: non-admin authenticated user is rejected with 401', async ({ page }) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({ page, email: nonAdminUser.email });
|
||||
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: true,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
expect(job).toBeNull();
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: organisation owner (non-admin) cannot delete their own org via admin route', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Owners can delete via the regular organisation.delete endpoint, but the
|
||||
// ADMIN endpoint must reject them too.
|
||||
const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: true,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: admin call with mismatched name is rejected and org is preserved', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: `${organisation.name}-WRONG`,
|
||||
sendEmailToOwner: true,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
// Body should contain INVALID_REQUEST error.
|
||||
const body = await res.text();
|
||||
expect(body).toContain('does not match');
|
||||
|
||||
const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
|
||||
// Most importantly: no job has been enqueued for this org.
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
expect(job).toBeNull();
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: admin call against non-existent organisation returns NOT_FOUND', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: 'org_does-not-exist-1234567890',
|
||||
organisationName: 'Anything',
|
||||
sendEmailToOwner: true,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const body = await res.text();
|
||||
expect(body).toContain('Organisation not found');
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: zod schema rejects malformed input', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Missing organisationName and sendEmailToOwner.
|
||||
const res = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/admin.organisation.delete`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({ json: { organisationId: 'whatever' } }),
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
// Zod validation failures surface as 400 from tRPC.
|
||||
expect([400, 422]).toContain(res.status());
|
||||
});
|
||||
|
||||
// ─── Happy path via tRPC (admin) ────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: admin can delete via the tRPC endpoint directly', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const res = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: false,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
// Background job should be enqueued; wait for it to complete then verify
|
||||
// the org is gone.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.admin-delete-organisation',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
return job?.status ?? null;
|
||||
},
|
||||
{ timeout: 30_000, intervals: [250, 500, 1000] },
|
||||
)
|
||||
.toBe('COMPLETED');
|
||||
|
||||
const org = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
expect(org).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Idempotency: calling delete twice does not throw ───────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][DELETE_ORG]: a second delete call after deletion is harmless (NOT_FOUND or no-op)', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// First call succeeds.
|
||||
const first = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: false,
|
||||
});
|
||||
expect(first.ok()).toBeTruthy();
|
||||
|
||||
// Wait for the deletion to actually happen.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const org = await prisma.organisation.findUnique({ where: { id: organisation.id } });
|
||||
return org === null;
|
||||
},
|
||||
{ timeout: 30_000, intervals: [250, 500, 1000] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// Second call: the org no longer exists, so the route should fail with
|
||||
// NOT_FOUND. It must NOT 500.
|
||||
const second = await callDeleteOrganisation(page, {
|
||||
organisationId: organisation.id,
|
||||
organisationName: organisation.name,
|
||||
sendEmailToOwner: false,
|
||||
});
|
||||
expect(second.ok()).toBeFalsy();
|
||||
expect(second.status()).not.toBe(500);
|
||||
|
||||
const body = await second.text();
|
||||
expect(body).toContain('Organisation not found');
|
||||
});
|
||||
@@ -2,12 +2,27 @@ import type { Page } from '@playwright/test';
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
await page.getByRole('tab', { name: tabName }).click();
|
||||
const statusMap: Record<string, string | undefined> = {
|
||||
Inbox: 'INBOX',
|
||||
Pending: 'PENDING',
|
||||
Completed: 'COMPLETED',
|
||||
Draft: 'DRAFT',
|
||||
All: undefined,
|
||||
};
|
||||
|
||||
if (tabName !== 'All') {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||
const currentUrl = new URL(page.url());
|
||||
const status = statusMap[tabName];
|
||||
|
||||
if (status) {
|
||||
currentUrl.searchParams.set('status', status);
|
||||
} else {
|
||||
currentUrl.searchParams.delete('status');
|
||||
}
|
||||
|
||||
currentUrl.searchParams.delete('page');
|
||||
|
||||
await page.goto(currentUrl.toString());
|
||||
|
||||
if (count === 0) {
|
||||
await expect(page.getByTestId('empty-document-state')).toBeVisible();
|
||||
return;
|
||||
|
||||
@@ -27,7 +27,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
await checkDocumentTabCount(page, 'All', 5);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('button', { name: /Sender/ }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
@@ -42,6 +42,21 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('[TEAMS]: supports filtering documents by multiple statuses', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: teamOwner.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING,DRAFT`,
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/status=PENDING,DRAFT/);
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 4');
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
|
||||
const { team, teamOwner, teamMember2, teamMember4 } = await seedTeamDocuments();
|
||||
const { team: team2, teamOwner: team2Owner, teamMember2: team2Member2 } = await seedTeamDocuments();
|
||||
@@ -122,7 +137,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
|
||||
await checkDocumentTabCount(page, 'All', 11);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('button', { name: /Sender/ }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
@@ -209,7 +224,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
||||
await checkDocumentTabCount(page, 'All', 9);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('button', { name: /Sender/ }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import { TeamMemberRole, TemplateType } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
@@ -40,6 +41,56 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: supports search and multi-type filtering', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const publicTemplate = await seedTemplate({
|
||||
title: 'Public Team Template',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const privateTemplate = await seedTemplate({
|
||||
title: 'Private Team Template',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await prisma.envelope.update({
|
||||
where: {
|
||||
id: publicTemplate.id,
|
||||
},
|
||||
data: {
|
||||
templateType: TemplateType.PUBLIC,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.envelope.update({
|
||||
where: {
|
||||
id: privateTemplate.id,
|
||||
},
|
||||
data: {
|
||||
templateType: TemplateType.PRIVATE,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/templates?query=Public&type=PUBLIC`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Public Team Template' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Private Team Template' })).not.toBeVisible();
|
||||
|
||||
await page.goto(`/t/${team.url}/templates?type=PUBLIC,PRIVATE`);
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Public Team Template' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Private Team Template' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
|
||||
@@ -41,7 +41,7 @@ export default defineConfig({
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:3000',
|
||||
baseURL: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'retain-on-failure',
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 MiB After Width: | Height: | Size: 3.9 MiB |
@@ -0,0 +1,75 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type OrganisationDeleteEmailProps = {
|
||||
assetBaseUrl: string;
|
||||
organisationName: string;
|
||||
/**
|
||||
* Whether the deletion was performed by an administrator (as opposed to the owner).
|
||||
* Slightly changes the wording in the email.
|
||||
*/
|
||||
deletedByAdmin?: boolean;
|
||||
};
|
||||
|
||||
export const OrganisationDeleteEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
organisationName = 'Organisation Name Placeholder',
|
||||
deletedByAdmin = false,
|
||||
}: OrganisationDeleteEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Your organisation has been deleted`;
|
||||
|
||||
const title = msg`Your organisation has been deleted`;
|
||||
|
||||
const description = deletedByAdmin
|
||||
? msg`The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data.`
|
||||
: msg`The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data.`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
{branding.brandingEnabled && branding.brandingLogo ? (
|
||||
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
|
||||
) : (
|
||||
<TemplateImage assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" staticAsset="logo.png" />
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-team.png" />
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center font-medium text-black text-lg">{_(title)}</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">{_(description)}</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
|
||||
{organisationName}
|
||||
</div>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganisationDeleteEmailTemplate;
|
||||
@@ -1,19 +1,37 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import type { NavigateOptions } from 'react-router';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
export const useUpdateSearchParams = () => {
|
||||
type SearchParamValues = Record<string, string | number | boolean | null | undefined>;
|
||||
type UpdateSearchParamsOptions = Pick<NavigateOptions, 'preventScrollReset' | 'replace' | 'state'>;
|
||||
|
||||
export const useUpdateSearchParams = (defaultOptions: UpdateSearchParamsOptions = {}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
return (params: Record<string, string | number | boolean | null | undefined>) => {
|
||||
const nextSearchParams = new URLSearchParams(searchParams?.toString() ?? '');
|
||||
const searchParamsRef = useRef(searchParams);
|
||||
searchParamsRef.current = searchParams;
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
nextSearchParams.delete(key);
|
||||
} else {
|
||||
nextSearchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
const defaultOptionsRef = useRef(defaultOptions);
|
||||
defaultOptionsRef.current = defaultOptions;
|
||||
|
||||
setSearchParams(nextSearchParams);
|
||||
};
|
||||
return useCallback(
|
||||
(params: SearchParamValues, options?: UpdateSearchParamsOptions) => {
|
||||
const nextSearchParams = new URLSearchParams(searchParamsRef.current?.toString() ?? '');
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
nextSearchParams.delete(key);
|
||||
} else {
|
||||
nextSearchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
setSearchParams(nextSearchParams, {
|
||||
...defaultOptionsRef.current,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
export const DOCUMENT_CONVERSION_MIME_TYPE_DOCX =
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
|
||||
const DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Returns whether the document conversion feature is enabled.
|
||||
*
|
||||
* Platform-aware:
|
||||
* - On the server, checks the private URL is configured.
|
||||
* - On the client, reads the derived public flag injected via `window.__ENV__`.
|
||||
*/
|
||||
export const IS_DOCUMENT_CONVERSION_ENABLED = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return !!env('NEXT_PRIVATE_DOCUMENT_CONVERSION_URL');
|
||||
}
|
||||
|
||||
return env('NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the configured conversion service base URL as supplied via env, or
|
||||
* `undefined` if not configured.
|
||||
*
|
||||
* Server-side only.
|
||||
*/
|
||||
export const DOCUMENT_CONVERSION_URL = (): string | undefined => {
|
||||
return env('NEXT_PRIVATE_DOCUMENT_CONVERSION_URL');
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns HTTP Basic auth credentials for the conversion service, or
|
||||
* `undefined` if either env var is missing. When Gotenberg is started with
|
||||
* `--api-enable-basic-auth`, every request must carry these credentials.
|
||||
*
|
||||
* Server-side only.
|
||||
*/
|
||||
export const DOCUMENT_CONVERSION_AUTH = (): { username: string; password: string } | undefined => {
|
||||
const username = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME');
|
||||
const password = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD');
|
||||
|
||||
if (!username || !password) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { username, password };
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the per-request timeout for conversion calls in milliseconds.
|
||||
*
|
||||
* Falls back to a 30 second default when the env value is missing or
|
||||
* unparseable.
|
||||
*/
|
||||
export const DOCUMENT_CONVERSION_TIMEOUT_MS = (): number => {
|
||||
const raw = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS');
|
||||
|
||||
if (!raw) {
|
||||
return DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
const parsed = parseInt(raw, 10);
|
||||
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
return DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the mime type -> extensions map that should be passed to the
|
||||
* dropzone `accept` config and used for server-side validation.
|
||||
*
|
||||
* Always includes PDF; only includes DOCX when the conversion feature is
|
||||
* enabled.
|
||||
*/
|
||||
export const getAllowedUploadMimeTypes = (): Record<string, string[]> => {
|
||||
const base: Record<string, string[]> = {
|
||||
'application/pdf': ['.pdf'],
|
||||
};
|
||||
|
||||
if (IS_DOCUMENT_CONVERSION_ENABLED()) {
|
||||
base[DOCUMENT_CONVERSION_MIME_TYPE_DOCX] = ['.docx'];
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
@@ -10,8 +10,10 @@ import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails
|
||||
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||
import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation';
|
||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
||||
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
|
||||
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
||||
@@ -49,6 +51,8 @@ export const jobsClient = new JobClient([
|
||||
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
|
||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
||||
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
||||
] as const);
|
||||
|
||||
export const jobs = jobsClient;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ORGANISATION_USER_ACCOUNT_TYPE } from '../../../constants/organisations';
|
||||
import { getEmailContext } from '../../../server-only/email/get-email-context';
|
||||
import { orphanEnvelopes } from '../../../server-only/envelope/orphan-envelopes';
|
||||
import { sendOrganisationDeleteEmail } from '../../../server-only/organisation/delete-organisation-email';
|
||||
import { jobs } from '../../client';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TAdminDeleteOrganisationJobDefinition } from './admin-delete-organisation';
|
||||
|
||||
export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJobDefinition; io: JobRunIO }) => {
|
||||
const { organisationId, sendEmailToOwner, requestedByUserId } = payload;
|
||||
|
||||
// Get/store the organisation in a task so it can be accessed by subsequent tasks.
|
||||
const organisation = await io.runTask('get-organisation', async () => {
|
||||
io.logger.info(`User ${requestedByUserId} is deleting organisation ${organisationId}`);
|
||||
|
||||
return await prisma.organisation.findUnique({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
subscription: {
|
||||
select: {
|
||||
planId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
// The organisation may have already been deleted by a prior run / another
|
||||
// pathway. Treat as a no-op so the job doesn't retry forever.
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerEmail = organisation.owner.email;
|
||||
|
||||
const emailContext = await io.runTask('get-email-context', async () => {
|
||||
return await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
type: 'organisation',
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 1. Orphan all envelopes for every team.
|
||||
for (const team of organisation.teams) {
|
||||
await io.runTask(`orphan-envelopes--team-${team.id}`, async () => {
|
||||
await orphanEnvelopes({ teamId: team.id });
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Delete the organisation. Matches the transaction in organisation-router/delete-organisation.ts.
|
||||
await io.runTask('delete-organisation', async () => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.deleteMany({
|
||||
where: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisation.delete({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Send the owner notification.
|
||||
if (sendEmailToOwner) {
|
||||
await io.runTask('send-organisation-deleted-email', async () => {
|
||||
await sendOrganisationDeleteEmail({
|
||||
email: ownerEmail,
|
||||
organisationName: organisation.name,
|
||||
deletedByAdmin: true,
|
||||
emailContext,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 4. If the organisation has a Stripe subscription, schedule it to be cancelled at the end of the current billing period.
|
||||
if (organisation.subscription) {
|
||||
const stripeSubscriptionId = organisation.subscription.planId;
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.cancel-organisation-subscription',
|
||||
payload: {
|
||||
stripeSubscriptionId,
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID = 'internal.admin-delete-organisation';
|
||||
|
||||
const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA = z.object({
|
||||
organisationId: z.string(),
|
||||
/**
|
||||
* Whether to email the organisation owner notifying them of the deletion.
|
||||
*/
|
||||
sendEmailToOwner: z.boolean(),
|
||||
/**
|
||||
* The id of the admin user who requested the deletion (for audit/logging).
|
||||
*/
|
||||
requestedByUserId: z.number(),
|
||||
});
|
||||
|
||||
export type TAdminDeleteOrganisationJobDefinition = z.infer<typeof ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA>;
|
||||
|
||||
export const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION = {
|
||||
id: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID,
|
||||
name: 'Admin Delete Organisation',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID,
|
||||
schema: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./admin-delete-organisation.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID,
|
||||
TAdminDeleteOrganisationJobDefinition
|
||||
>;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Stripe, stripe } from '../../../server-only/stripe';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TCancelOrganisationSubscriptionJobDefinition } from './cancel-organisation-subscription';
|
||||
|
||||
/**
|
||||
* Marks the given Stripe subscription for cancellation at the end of the
|
||||
* current billing period.
|
||||
*
|
||||
* Idempotent: calling this on an already-cancel-at-period-end subscription is
|
||||
* a no-op for Stripe and returns the same shape, so re-running the job after
|
||||
* a partial failure is safe.
|
||||
*
|
||||
* If the subscription no longer exists in Stripe (`resource_missing`), the
|
||||
* job treats it as a no-op rather than retrying forever \u2014 nothing further
|
||||
* can be done.
|
||||
*/
|
||||
export const run = async ({ payload }: { payload: TCancelOrganisationSubscriptionJobDefinition; io: JobRunIO }) => {
|
||||
const { stripeSubscriptionId } = payload;
|
||||
|
||||
try {
|
||||
await stripe.subscriptions.update(stripeSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
});
|
||||
} catch (error) {
|
||||
// Subscription no longer exists in Stripe \u2014 nothing to cancel. Treat as
|
||||
// success so the job doesn't retry indefinitely.
|
||||
if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === 'resource_missing') {
|
||||
console.warn(
|
||||
`[CANCEL_ORGANISATION_SUBSCRIPTION] Stripe subscription ${stripeSubscriptionId} no longer exists; skipping.`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Anything else: rethrow so the job runner retries.
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID = 'internal.cancel-organisation-subscription';
|
||||
|
||||
const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA = z.object({
|
||||
/**
|
||||
* The Stripe subscription id (Subscription.planId in our schema).
|
||||
*
|
||||
* This must be captured before the local organisation row is deleted,
|
||||
* because the Subscription row cascades away when the organisation is
|
||||
* removed.
|
||||
*/
|
||||
stripeSubscriptionId: z.string(),
|
||||
/**
|
||||
* The organisation id, for logging only. The organisation may no longer
|
||||
* exist by the time this job runs.
|
||||
*/
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export type TCancelOrganisationSubscriptionJobDefinition = z.infer<
|
||||
typeof CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION = {
|
||||
id: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID,
|
||||
name: 'Cancel Organisation Subscription',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID,
|
||||
schema: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./cancel-organisation-subscription.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID,
|
||||
TCancelOrganisationSubscriptionJobDefinition
|
||||
>;
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* In-process circuit breaker for the document conversion service.
|
||||
*
|
||||
* Behaviour: any failure opens the circuit for `COOLDOWN_MS`. While open,
|
||||
* callers should fail fast without hitting the network. The first request
|
||||
* after the cooldown is allowed through and either closes the circuit (on
|
||||
* success) or re-opens it for another cooldown window (on failure).
|
||||
*
|
||||
* State is stored on `globalThis` so it survives Vite/Remix HMR in dev and
|
||||
* is unambiguously process-wide. This module is intentionally pure and
|
||||
* synchronous: no I/O, no logger import — callers handle observability.
|
||||
*/
|
||||
|
||||
const COOLDOWN_MS = 30_000;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __documensoConversionCircuitOpenedAt: number | null | undefined;
|
||||
}
|
||||
|
||||
export const isCircuitOpen = (): boolean => {
|
||||
const openedAt = globalThis.__documensoConversionCircuitOpenedAt;
|
||||
|
||||
if (!openedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() - openedAt < COOLDOWN_MS;
|
||||
};
|
||||
|
||||
export const recordSuccess = (): void => {
|
||||
globalThis.__documensoConversionCircuitOpenedAt = null;
|
||||
};
|
||||
|
||||
export const recordFailure = (): void => {
|
||||
globalThis.__documensoConversionCircuitOpenedAt = Date.now();
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import {
|
||||
DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
|
||||
IS_DOCUMENT_CONVERSION_ENABLED,
|
||||
} from '../../constants/document-conversion';
|
||||
import { isCircuitOpen, recordFailure, recordSuccess } from './circuit-breaker';
|
||||
import { convertDocxToPdfViaGotenberg } from './gotenberg';
|
||||
|
||||
type ConvertDocxToPdfOptions = {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
const NOT_CONFIGURED_USER_MESSAGE = "Document conversion isn't enabled on this instance. Please upload a PDF.";
|
||||
|
||||
const UNAVAILABLE_USER_MESSAGE =
|
||||
'Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.';
|
||||
|
||||
/**
|
||||
* Converts a DOCX buffer to a PDF buffer via the configured Gotenberg
|
||||
* conversion service. Guards on feature-enabled and circuit-open state,
|
||||
* and emits a structured log line for each attempt.
|
||||
*/
|
||||
export const convertDocxToPdf = async (
|
||||
{ buffer, filename }: ConvertDocxToPdfOptions,
|
||||
logger?: Logger,
|
||||
): Promise<Buffer> => {
|
||||
if (!IS_DOCUMENT_CONVERSION_ENABLED()) {
|
||||
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
|
||||
message: 'Conversion service not configured',
|
||||
userMessage: NOT_CONFIGURED_USER_MESSAGE,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (isCircuitOpen()) {
|
||||
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
|
||||
message: 'Conversion circuit is open; failing fast',
|
||||
userMessage: UNAVAILABLE_USER_MESSAGE,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const outputBuffer = await convertDocxToPdfViaGotenberg({ buffer, filename });
|
||||
|
||||
recordSuccess();
|
||||
|
||||
logger?.info({
|
||||
event: 'document_conversion_attempt',
|
||||
filename,
|
||||
sourceMimeType: DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
|
||||
durationMs: Date.now() - startedAt,
|
||||
inputBytes: buffer.byteLength,
|
||||
outputBytes: outputBuffer.byteLength,
|
||||
});
|
||||
|
||||
return outputBuffer;
|
||||
} catch (err) {
|
||||
recordFailure();
|
||||
|
||||
const errMessage = err instanceof Error ? err.message : String(err);
|
||||
const errCode = err instanceof AppError ? err.code : 'UNKNOWN';
|
||||
|
||||
const logData = {
|
||||
event: 'document_conversion_attempt',
|
||||
filename,
|
||||
sourceMimeType: DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
|
||||
durationMs: Date.now() - startedAt,
|
||||
inputBytes: buffer.byteLength,
|
||||
failed: true,
|
||||
errorCode: errCode,
|
||||
error: errMessage,
|
||||
};
|
||||
|
||||
// A non-2xx from the conversion service surfaces as CONVERSION_FAILED.
|
||||
// We log those at `error` level (status + truncated body live in the
|
||||
// AppError message). All other failures stay at `info` to avoid noisy
|
||||
// logs from transient network blips that the breaker already handles.
|
||||
if (errCode === 'CONVERSION_FAILED') {
|
||||
logger?.error(logData);
|
||||
} else {
|
||||
logger?.info(logData);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import {
|
||||
DOCUMENT_CONVERSION_AUTH,
|
||||
DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
|
||||
DOCUMENT_CONVERSION_TIMEOUT_MS,
|
||||
DOCUMENT_CONVERSION_URL,
|
||||
} from '../../constants/document-conversion';
|
||||
|
||||
type ConvertDocxToPdfViaGotenbergOptions = {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
const UNAVAILABLE_USER_MESSAGE =
|
||||
'Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.';
|
||||
|
||||
const NOT_CONFIGURED_USER_MESSAGE = "Document conversion isn't enabled on this instance. Please upload a PDF.";
|
||||
|
||||
const CONVERSION_FAILED_USER_MESSAGE =
|
||||
"We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.";
|
||||
|
||||
const MAX_ERROR_BODY_CHARS = 500;
|
||||
|
||||
/**
|
||||
* Posts a DOCX file to the configured Gotenberg-compatible conversion
|
||||
* service and returns the resulting PDF bytes.
|
||||
*
|
||||
* Throws an `AppError` for all failure modes:
|
||||
* - `CONVERSION_SERVICE_UNAVAILABLE` for missing config, timeout, or
|
||||
* network errors.
|
||||
* - `CONVERSION_FAILED` for non-2xx responses from the service.
|
||||
*/
|
||||
export const convertDocxToPdfViaGotenberg = async ({
|
||||
buffer,
|
||||
filename,
|
||||
}: ConvertDocxToPdfViaGotenbergOptions): Promise<Buffer> => {
|
||||
const url = DOCUMENT_CONVERSION_URL();
|
||||
|
||||
if (!url) {
|
||||
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
|
||||
message: 'Conversion service URL is not configured',
|
||||
userMessage: NOT_CONFIGURED_USER_MESSAGE,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([buffer], { type: DOCUMENT_CONVERSION_MIME_TYPE_DOCX });
|
||||
|
||||
formData.append('files', blob, filename);
|
||||
|
||||
// Tell LibreOffice NOT to export Word content controls (`<w:sdt>`) as PDF
|
||||
// AcroForm fields. By default Gotenberg renders the field values into form
|
||||
// appearance streams that reference unembedded base fonts (Times-Roman,
|
||||
// Times-Bold). Our downstream `normalizePdf` flattens the form, but the
|
||||
// pdf-lib flattening drops those appearance streams, so every SDT-bound
|
||||
// string (i.e. virtually all of the body text in Office resume / cover-
|
||||
// letter templates) ends up invisible in the final PDF. Disabling form
|
||||
// export makes LibreOffice render those strings as regular text in the
|
||||
// page content stream, with all glyphs embedded.
|
||||
formData.append('exportFormFields', 'false');
|
||||
|
||||
// When the service is launched with `--api-enable-basic-auth`, every
|
||||
// route (including `/health` and `/forms/libreoffice/convert`) requires
|
||||
// HTTP Basic credentials. When auth env vars are not configured we send
|
||||
// no header and rely on the service running without auth enabled.
|
||||
const auth = DOCUMENT_CONVERSION_AUTH();
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (auth) {
|
||||
const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
|
||||
headers.Authorization = `Basic ${encoded}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(() => controller.abort(), DOCUMENT_CONVERSION_TIMEOUT_MS());
|
||||
|
||||
const convertEndpoint = new URL('/forms/libreoffice/convert', url).toString();
|
||||
|
||||
try {
|
||||
const response = await fetch(convertEndpoint, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let body = '';
|
||||
|
||||
try {
|
||||
body = await response.text();
|
||||
} catch {
|
||||
body = '';
|
||||
}
|
||||
|
||||
const truncatedBody = body.length > MAX_ERROR_BODY_CHARS ? `${body.slice(0, MAX_ERROR_BODY_CHARS)}...` : body;
|
||||
|
||||
throw new AppError('CONVERSION_FAILED', {
|
||||
message: `Conversion service returned ${response.status}: ${truncatedBody}`,
|
||||
userMessage: CONVERSION_FAILED_USER_MESSAGE,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
return Buffer.from(arrayBuffer);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const isAbortError = err instanceof Error && err.name === 'AbortError';
|
||||
|
||||
if (isAbortError) {
|
||||
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
|
||||
message: 'Conversion service timed out',
|
||||
userMessage: UNAVAILABLE_USER_MESSAGE,
|
||||
statusCode: 503,
|
||||
});
|
||||
}
|
||||
|
||||
const errMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
|
||||
message: `Conversion service request failed: ${errMessage}`,
|
||||
userMessage: UNAVAILABLE_USER_MESSAGE,
|
||||
statusCode: 503,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
import { DOCUMENT_CONVERSION_MIME_TYPE_DOCX } from '../../constants/document-conversion';
|
||||
import { convertDocxToPdf } from './docx-to-pdf';
|
||||
|
||||
// We should work on unifying these later on.
|
||||
type FileInput = {
|
||||
name: string;
|
||||
type: string;
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
const UNSUPPORTED_USER_MESSAGE = "This file type isn't supported. Please upload a PDF or Word document.";
|
||||
|
||||
/**
|
||||
* Entry point for upload routes. Returns a PDF buffer for any supported
|
||||
* input file:
|
||||
*
|
||||
* - PDF in → PDF out (no conversion, no network call).
|
||||
* - DOCX in → converted PDF out via the configured conversion service.
|
||||
* - Any other mime type → throws `UNSUPPORTED_FILE_TYPE`.
|
||||
*
|
||||
* To support new source formats (PowerPoint, HTML, ...), add a new
|
||||
* `<format>-to-pdf.ts` sibling and dispatch to it from here.
|
||||
*/
|
||||
export const convertToPdf = async (file: FileInput, logger?: Logger): Promise<Buffer> => {
|
||||
if (file.type === 'application/pdf') {
|
||||
return Buffer.from(await file.arrayBuffer());
|
||||
}
|
||||
|
||||
if (file.type === DOCUMENT_CONVERSION_MIME_TYPE_DOCX) {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
return convertDocxToPdf({ buffer, filename: file.name }, logger);
|
||||
}
|
||||
|
||||
throw new AppError('UNSUPPORTED_FILE_TYPE', {
|
||||
message: `Unsupported file type: ${file.type}`,
|
||||
userMessage: UNSUPPORTED_USER_MESSAGE,
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
@@ -18,14 +18,31 @@ import type { FindResultResponse } from '../../types/search-params';
|
||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
|
||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||
export type PeriodSelectorValue = '' | 'all' | '7d' | '14d' | '30d';
|
||||
|
||||
const normalizeStatuses = (
|
||||
status: ExtendedDocumentStatus | ExtendedDocumentStatus[] | undefined,
|
||||
): ExtendedDocumentStatus[] => {
|
||||
if (!status) {
|
||||
return [ExtendedDocumentStatus.ALL];
|
||||
}
|
||||
|
||||
const arr = Array.isArray(status) ? status : [status];
|
||||
const deduped = Array.from(new Set(arr));
|
||||
|
||||
if (deduped.length === 0 || deduped.includes(ExtendedDocumentStatus.ALL)) {
|
||||
return [ExtendedDocumentStatus.ALL];
|
||||
}
|
||||
|
||||
return deduped;
|
||||
};
|
||||
|
||||
export type FindDocumentsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
templateId?: number;
|
||||
source?: DocumentSource;
|
||||
status?: ExtendedDocumentStatus;
|
||||
source?: DocumentSource | DocumentSource[];
|
||||
status?: ExtendedDocumentStatus | ExtendedDocumentStatus[];
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
@@ -107,7 +124,7 @@ export const findDocuments = async ({
|
||||
teamId,
|
||||
templateId,
|
||||
source,
|
||||
status = ExtendedDocumentStatus.ALL,
|
||||
status,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
@@ -135,6 +152,8 @@ export const findDocuments = async ({
|
||||
const hasSearch = searchQuery.length > 0;
|
||||
const searchPattern = `%${searchQuery}%`;
|
||||
|
||||
const normalizedStatuses = normalizeStatuses(status);
|
||||
|
||||
// ─── Base query with common filters ──────────────────────────────────
|
||||
//
|
||||
// Every code path starts from this base: Envelope rows filtered by type,
|
||||
@@ -151,7 +170,7 @@ export const findDocuments = async ({
|
||||
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
|
||||
|
||||
// Period filter
|
||||
if (period) {
|
||||
if (period && period !== 'all') {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
@@ -165,7 +184,15 @@ export const findDocuments = async ({
|
||||
|
||||
// Source filter (enum cast)
|
||||
if (source) {
|
||||
qb = qb.where('Envelope.source', '=', sql.lit(source));
|
||||
const sources = Array.isArray(source) ? source : [source];
|
||||
|
||||
if (sources.length > 0) {
|
||||
qb = qb.where(
|
||||
'Envelope.source',
|
||||
'in',
|
||||
sources.map((s) => sql.lit(s)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Template filter
|
||||
@@ -204,35 +231,32 @@ export const findDocuments = async ({
|
||||
|
||||
// ─── Personal path filters ───────────────────────────────────────────
|
||||
|
||||
const applyPersonalFilters = (qb: EnvelopeQueryBuilder): EnvelopeQueryBuilder | null => {
|
||||
const buildPersonalStatusPredicate = (
|
||||
eb: EnvelopeExpressionBuilder,
|
||||
s: ExtendedDocumentStatus,
|
||||
): Expression<SqlBool> => {
|
||||
// Deleted filter: owned → deletedAt IS NULL, received → documentDeletedAt IS NULL
|
||||
const personalDeletedFilter = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.or([
|
||||
eb.and([eb('Envelope.userId', '=', user.id), eb('Envelope.deletedAt', 'is', null)]),
|
||||
recipientExists(eb, user.email, (reb) => reb('Recipient.documentDeletedAt', 'is', null)),
|
||||
]);
|
||||
const personalDeletedFilter = eb.or([
|
||||
eb.and([eb('Envelope.userId', '=', user.id), eb('Envelope.deletedAt', 'is', null)]),
|
||||
recipientExists(eb, user.email, (reb) => reb('Recipient.documentDeletedAt', 'is', null)),
|
||||
]);
|
||||
|
||||
return match<ExtendedDocumentStatus, EnvelopeQueryBuilder | null>(status)
|
||||
return match<ExtendedDocumentStatus, Expression<SqlBool>>(s)
|
||||
.with(ExtendedDocumentStatus.ALL, () =>
|
||||
qb.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb.and([
|
||||
eb('Envelope.status', 'in', [sql.lit(DocumentStatus.COMPLETED), sql.lit(DocumentStatus.PENDING)]),
|
||||
recipientExists(eb, user.email),
|
||||
]),
|
||||
eb.and([
|
||||
personalDeletedFilter,
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb.and([
|
||||
eb('Envelope.status', 'in', [sql.lit(DocumentStatus.COMPLETED), sql.lit(DocumentStatus.PENDING)]),
|
||||
recipientExists(eb, user.email),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.INBOX, () =>
|
||||
qb.where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) =>
|
||||
// Single EXISTS check: the recipient must be NOT_SIGNED, non-CC, and
|
||||
// not soft-deleted. This replaces the previous personalDeletedFilter +
|
||||
// separate recipientExists pair, eliminating a hashed SubPlan that
|
||||
// materialised all recipient rows for this email (~125k for heavy users).
|
||||
eb.and([
|
||||
eb('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT)),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.documentDeletedAt', 'is', null),
|
||||
@@ -240,66 +264,62 @@ export const findDocuments = async ({
|
||||
reb('role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.DRAFT, () =>
|
||||
qb
|
||||
.where('Envelope.userId', '=', user.id)
|
||||
.where('Envelope.deletedAt', 'is', null)
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)),
|
||||
eb.and([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
eb('Envelope.deletedAt', 'is', null),
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)),
|
||||
]),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.PENDING, () =>
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)),
|
||||
personalDeletedFilter,
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () =>
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
),
|
||||
eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED)),
|
||||
personalDeletedFilter,
|
||||
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
|
||||
]),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.REJECTED, () =>
|
||||
qb
|
||||
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED))
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
personalDeletedFilter(eb),
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED)),
|
||||
personalDeletedFilter,
|
||||
eb.or([
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const applyPersonalFilters = (qb: EnvelopeQueryBuilder): EnvelopeQueryBuilder =>
|
||||
qb.where((eb) => eb.or(normalizedStatuses.map((s) => buildPersonalStatusPredicate(eb, s))));
|
||||
|
||||
// ─── Team path filters ───────────────────────────────────────────────
|
||||
|
||||
const applyTeamFilters = (
|
||||
qb: EnvelopeQueryBuilder,
|
||||
const buildTeamStatusPredicate = (
|
||||
eb: EnvelopeExpressionBuilder,
|
||||
teamData: Team & { teamEmail: TeamEmail | null; currentTeamRole: TeamMemberRole },
|
||||
): EnvelopeQueryBuilder | null => {
|
||||
s: ExtendedDocumentStatus,
|
||||
): Expression<SqlBool> | null => {
|
||||
const teamEmail = teamData.teamEmail?.email ?? null;
|
||||
|
||||
const allowedVisibilities = match(teamData.currentTeamRole)
|
||||
@@ -311,127 +331,152 @@ export const findDocuments = async ({
|
||||
.with(TeamMemberRole.MANAGER, () => [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE])
|
||||
.otherwise(() => [DocumentVisibility.EVERYONE]);
|
||||
|
||||
// Visibility: meets role threshold OR directly involved
|
||||
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
|
||||
eb.or([
|
||||
eb(
|
||||
'Envelope.visibility',
|
||||
'in',
|
||||
allowedVisibilities.map((v) => sql.lit(v)),
|
||||
),
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email),
|
||||
]);
|
||||
const visibilityFilter = eb.or([
|
||||
eb(
|
||||
'Envelope.visibility',
|
||||
'in',
|
||||
allowedVisibilities.map((v) => sql.lit(v)),
|
||||
),
|
||||
eb('Envelope.userId', '=', user.id),
|
||||
recipientExists(eb, user.email),
|
||||
]);
|
||||
|
||||
// Deleted filter for team path
|
||||
const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => {
|
||||
const branches = [eb.and([eb('Envelope.teamId', '=', teamData.id), eb('Envelope.deletedAt', 'is', null)])];
|
||||
const teamDeletedBranches = [
|
||||
eb.and([eb('Envelope.teamId', '=', teamData.id), eb('Envelope.deletedAt', 'is', null)]),
|
||||
];
|
||||
|
||||
if (teamEmail) {
|
||||
branches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)]));
|
||||
branches.push(recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)));
|
||||
}
|
||||
if (teamEmail) {
|
||||
teamDeletedBranches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)]));
|
||||
teamDeletedBranches.push(recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)));
|
||||
}
|
||||
|
||||
return eb.or(branches);
|
||||
};
|
||||
const teamDeletedFilter = eb.or(teamDeletedBranches);
|
||||
|
||||
return match<ExtendedDocumentStatus, EnvelopeQueryBuilder | null>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () =>
|
||||
qb.where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
return match<ExtendedDocumentStatus, Expression<SqlBool> | null>(s)
|
||||
.with(ExtendedDocumentStatus.ALL, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
eb.and([eb('status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)), recipientExists(eb, teamEmail)]),
|
||||
);
|
||||
}
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
eb.and([eb('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT)), recipientExists(eb, teamEmail)]),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
return eb.and([teamDeletedFilter, visibilityFilter, eb.or(accessBranches)]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.INBOX, () => {
|
||||
if (!teamEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return qb.where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) =>
|
||||
eb.and([
|
||||
visibilityFilter(eb),
|
||||
// Single EXISTS check: the team-email recipient must be NOT_SIGNED,
|
||||
// non-CC, and not soft-deleted. Replaces teamDeletedFilter + separate
|
||||
// recipientExists, eliminating a hashed SubPlan (~79k rows).
|
||||
return eb.and([
|
||||
eb('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT)),
|
||||
visibilityFilter,
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.documentDeletedAt', 'is', null),
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.PENDING, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.documentDeletedAt', 'is', null),
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)),
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.DRAFT, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
}
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.PENDING, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.PENDING)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.with(ExtendedDocumentStatus.REJECTED, () => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb.and([
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)),
|
||||
reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(recipientExists(eb, teamEmail));
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
.with(ExtendedDocumentStatus.REJECTED, () =>
|
||||
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED)).where((eb) => {
|
||||
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
|
||||
|
||||
if (teamEmail) {
|
||||
accessBranches.push(senderEmailIs(eb, teamEmail));
|
||||
accessBranches.push(
|
||||
recipientExists(eb, teamEmail, (reb) =>
|
||||
reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
|
||||
}),
|
||||
)
|
||||
return eb.and([
|
||||
eb('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED)),
|
||||
teamDeletedFilter,
|
||||
visibilityFilter,
|
||||
eb.or(accessBranches),
|
||||
]);
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const applyTeamFilters = (
|
||||
qb: EnvelopeQueryBuilder,
|
||||
teamData: Team & { teamEmail: TeamEmail | null; currentTeamRole: TeamMemberRole },
|
||||
): EnvelopeQueryBuilder | null => {
|
||||
const teamEmail = teamData.teamEmail?.email ?? null;
|
||||
|
||||
// INBOX requires a team email; drop statuses that produce no predicate.
|
||||
const validStatuses = normalizedStatuses.filter((s) => !(s === ExtendedDocumentStatus.INBOX && !teamEmail));
|
||||
|
||||
if (validStatuses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return qb.where((eb) => {
|
||||
const predicates = validStatuses
|
||||
.map((s) => buildTeamStatusPredicate(eb, teamData, s))
|
||||
.filter((p): p is Expression<SqlBool> => p !== null);
|
||||
|
||||
return eb.or(predicates);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Assemble and execute ────────────────────────────────────────────
|
||||
|
||||
const baseQuery = buildBaseQuery();
|
||||
|
||||
@@ -109,7 +109,7 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
|
||||
folderId !== undefined ? qb.where('Envelope.folderId', '=', folderId) : qb.where('Envelope.folderId', 'is', null);
|
||||
|
||||
// Period filter
|
||||
if (period) {
|
||||
if (period && period !== 'all') {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & {
|
||||
|
||||
type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions;
|
||||
|
||||
type EmailContextResponse = {
|
||||
export type EmailContextResponse = {
|
||||
allowedEmails: OrganisationEmail[];
|
||||
branding: BrandingSettings;
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { OrganisationDeleteEmailTemplate } from '@documenso/email/templates/organisation-delete';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import type { EmailContextResponse } from '../email/get-email-context';
|
||||
|
||||
export type SendOrganisationDeleteEmailOptions = {
|
||||
email: string;
|
||||
organisationName: string;
|
||||
deletedByAdmin?: boolean;
|
||||
emailContext: EmailContextResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends an "organisation deleted" notification email.
|
||||
*/
|
||||
export const sendOrganisationDeleteEmail = async ({
|
||||
email,
|
||||
organisationName,
|
||||
deletedByAdmin = false,
|
||||
emailContext,
|
||||
}: SendOrganisationDeleteEmailOptions) => {
|
||||
const template = createElement(OrganisationDeleteEmailTemplate, {
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
organisationName,
|
||||
deletedByAdmin,
|
||||
});
|
||||
|
||||
const { branding, emailLanguage, senderEmail } = emailContext;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
|
||||
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: senderEmail,
|
||||
subject: i18n._(msg`Organisation "${organisationName}" has been deleted`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
@@ -9,7 +9,8 @@ import { getMemberRoles } from '../team/get-member-roles';
|
||||
export type FindTemplatesOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
type?: TemplateType;
|
||||
type?: TemplateType | TemplateType[];
|
||||
query?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
folderId?: string;
|
||||
@@ -19,6 +20,7 @@ export const findTemplates = async ({
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
query = '',
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
folderId,
|
||||
@@ -31,9 +33,11 @@ export const findTemplates = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const templateTypeFilter = type ? { in: Array.isArray(type) ? type : [type] } : undefined;
|
||||
|
||||
const where: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
templateType: type,
|
||||
templateType: templateTypeFilter,
|
||||
AND: [
|
||||
{ teamId },
|
||||
{
|
||||
@@ -47,6 +51,26 @@ export const findTemplates = async ({
|
||||
],
|
||||
},
|
||||
folderId ? { folderId } : { folderId: null },
|
||||
...(query
|
||||
? [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
contains: query,
|
||||
mode: 'insensitive' as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
externalId: {
|
||||
contains: query,
|
||||
mode: 'insensitive' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: de\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-11 09:07\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -630,16 +630,6 @@ msgstr "{validationLength, plural, one {Wählen Sie genau # Option} other {Wähl
|
||||
msgid "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
msgstr "{visibleRows, plural, one {Eine # Ergebnis wird angezeigt.} other {# Ergebnisse werden angezeigt.}}"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
msgstr "/* Schreiben Sie CSS, das auf Ihre Signaturseiten abzielt. Selektoren werden automatisch eingeschränkt. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
|
||||
#. placeholder {0}: document.title
|
||||
#. placeholder {0}: envelope.title
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
@@ -1631,6 +1621,7 @@ msgstr "Eine E-Mail mit einer Einladung wird an jedes Mitglied gesendet."
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "Eine E-Mail mit dieser Adresse existiert bereits."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2398,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "Person nicht gefunden?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2881,6 +2873,7 @@ msgstr "Konfigurieren Sie, wann und wie oft Erinnerungs-E-Mails an Empfänger ge
|
||||
msgid "Confirm"
|
||||
msgstr "Bestätigen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3410,6 +3403,10 @@ msgstr "Benutzerdefinierte Organisationsgruppen"
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "Passen Sie die Farben an, die auf Ihren Signaturseiten verwendet werden."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "Gefahrenbereich"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Dunkelmodus"
|
||||
@@ -3542,6 +3539,8 @@ msgstr "Dokumenteigentum delegieren"
|
||||
msgid "delete"
|
||||
msgstr "löschen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3591,6 +3590,10 @@ msgstr "löschen {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "löschen {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "{organisationName} löschen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "löschen {teamName}"
|
||||
@@ -3643,6 +3646,8 @@ msgstr "Umschlag löschen"
|
||||
msgid "Delete Folder"
|
||||
msgstr "Ordner löschen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Organisation löschen"
|
||||
@@ -3705,6 +3710,10 @@ msgstr "Gelöscht"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Konto wird gelöscht..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "Löschung geplant"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Ziel"
|
||||
@@ -3999,6 +4008,12 @@ msgstr "E-Mail zum Abschluss des Dokuments"
|
||||
msgid "Document Completed!"
|
||||
msgstr "Dokument abgeschlossen!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "Die Dokumentkonvertierung ist vorübergehend nicht verfügbar. Bitte versuchen Sie es in Kürze erneut oder laden Sie ein PDF hoch."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Dokument erstellt"
|
||||
@@ -4440,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Entwurfte Dokumente"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Ziehen Sie Ihr PDF hierher."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "Ziehen Sie Ihr Dokument hierher."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Ziehen Sie die Datei hierher oder klicken Sie, um hochzuladen"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Ziehen Sie Ihre PDF-Datei hierher"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "Ziehen Sie Ihr Dokument hierher"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4749,6 +4764,10 @@ msgstr "E-Mail gesendet!"
|
||||
msgid "Email Settings"
|
||||
msgstr "E-Mail-Einstellungen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "Senden Sie eine E-Mail an den/die Organisationsinhaber(in), um ihn/sie über die Löschung zu informieren."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "Den Eigentümer per E-Mail benachrichtigen, wenn ein Dokument aus einer direkten Vorlage erstellt wird"
|
||||
@@ -6132,6 +6151,10 @@ msgstr "Rechnung"
|
||||
msgid "IP Address"
|
||||
msgstr "IP-Adresse"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "Irreversible Aktionen für diese Organisation"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "Herausgeber-URL"
|
||||
@@ -7315,6 +7338,10 @@ msgstr "Oder fahren Sie fort mit"
|
||||
msgid "Organisation"
|
||||
msgstr "Organisation"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "Organisation \"{organisationName}\" wurde gelöscht"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Organisationsadministrator"
|
||||
@@ -7680,6 +7707,10 @@ msgstr "pro Monat"
|
||||
msgid "per year"
|
||||
msgstr "pro Jahr"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "Diese Organisation dauerhaft löschen. Dokumente werden verwaist (nicht gelöscht), damit sie weiterhin über das Servicekonto für gelöschte Konten zugänglich bleiben."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -10375,6 +10406,10 @@ msgstr "Der Inhalt, der im Banne rgezeig wird, HTML ist erlaubt"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "Die Standard-E-Mail, die beim Versenden von E-Mails an Empfänger verwendet wird"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "Die Löschung wird im Hintergrund ausgeführt und kann bis zu einigen Minuten dauern. Führen Sie diese Löschung nicht erneut aus."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10500,6 +10535,14 @@ msgstr "Der Ordner, in den Sie die Vorlage verschieben möchten, existiert nicht
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "Die folgenden Fehler sind aufgetreten:"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "Die folgende Organisation wurde von einem Administrator gelöscht. Sie und Ihre Mitglieder können nicht mehr auf diese Organisation, ihre Teams oder ihre zugehörigen Daten zugreifen."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "Die folgende Organisation wurde gelöscht. Sie und Ihre Mitglieder können nicht mehr auf diese Organisation, ihre Teams oder ihre zugehörigen Daten zugreifen."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "Für die folgenden Empfänger wird eine E‑Mail‑Adresse benötigt:"
|
||||
@@ -10538,6 +10581,10 @@ msgstr "Die gesuchte Organisationsgruppe wurde möglicherweise entfernt, umbenan
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "Die Organisationsrolle, die auf alle Mitglieder in dieser Gruppe angewendet wird."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "Die Organisation wird im Hintergrund gelöscht. Dokumente werden verwaist und nicht gelöscht."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10926,6 +10973,12 @@ msgstr "Diese Funktion ist in Ihrem aktuellen Tarif nicht verfügbar."
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "Dieses Feld kann nicht geändert oder gelöscht werden. Wenn Sie den direkten Link dieser Vorlage teilen oder zu Ihrem öffentlichen Profil hinzufügen, kann jeder, der darauf zugreift, seinen Namen und seine E-Mail-Adresse eingeben und die ihm zugewiesenen Felder ausfüllen."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "Dieser Dateityp wird nicht unterstützt. Bitte laden Sie ein PDF- oder Word-Dokument hoch."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "Dieser Ordner enthält mehrere Elemente. Wenn Sie ihn löschen, werden alle Unterordner entfernt und alle darin enthaltenen Dokumente und Vorlagen in den Stammordner verschoben."
|
||||
@@ -12197,6 +12250,12 @@ msgstr "Wir können diesen Schlüssel im Moment nicht entfernen. Bitte versuchen
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "Wir können diesen Schlüssel im Moment nicht aktualisieren. Bitte versuchen Sie es später erneut."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "Wir konnten diese Datei nicht konvertieren. Bitte prüfen Sie, ob es sich um ein gültiges Word-Dokument handelt, oder laden Sie stattdessen ein PDF hoch."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "Wir konnten keinen Stripe-Kunden erstellen. Bitte versuchen Sie es erneut."
|
||||
@@ -12223,6 +12282,10 @@ msgstr "Wir konnten die Organisation nicht aktualisieren. Bitte versuchen Sie es
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "Der Anbieter konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "Beim Versuch, diese Organisation zu löschen, ist ein Fehler aufgetreten. Bitte versuche es später noch einmal."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12726,6 +12789,10 @@ msgstr "Sie sind dabei, <0>\"{title}\"</0> zu löschen"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "Sie sind dabei, <0>{0}</0> zu löschen. Alle mit dieser Organisation verbundenen Daten wie Teams, Dokumente und alle anderen Ressourcen werden gelöscht. Diese Aktion ist nicht umkehrbar."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "Du bist dabei, <0>{organisationName}</0> zu löschen. Diese Aktion kann nicht rückgängig gemacht werden. Alle Teams werden entfernt und alle Dokumente werden dem Servicekonto des gelöschten Kontos zugeordnet."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "Sie stehen kurz davor, die folgende Team-E-Mail von <0>{teamName}</0> zu löschen."
|
||||
@@ -13208,6 +13275,7 @@ msgstr "Sie haben Ihre E-Mail-Adresse für <0>{0}</0> bestätigt."
|
||||
msgid "You moved the document to team"
|
||||
msgstr "Sie haben das Dokument in das Team verschoben"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13653,6 +13721,11 @@ msgstr "Ihr neues Passwort darf nicht mit Ihrem alten Passwort identisch sein."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "Ihre Organisation wurde erstellt."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "Deine Organisation wurde gelöscht"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "Ihre Organisation wurde erfolgreich gelöscht."
|
||||
|
||||
@@ -625,18 +625,6 @@ msgstr "{validationLength, plural, one {Select exactly # option} other {Select e
|
||||
msgid "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
msgstr "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid ""
|
||||
"/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
msgstr ""
|
||||
"/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
|
||||
#. placeholder {0}: document.title
|
||||
#. placeholder {0}: envelope.title
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
@@ -1628,6 +1616,7 @@ msgstr "An email containing an invitation will be sent to each member."
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "An email with this address already exists."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2395,6 +2384,7 @@ msgid "Can't find someone?"
|
||||
msgstr "Can't find someone?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2878,6 +2868,7 @@ msgstr "Configure when and how often reminder emails are sent to recipients who
|
||||
msgid "Confirm"
|
||||
msgstr "Confirm"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3407,6 +3398,10 @@ msgstr "Custom Organisation Groups"
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "Customise the colours used on your signing pages."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "Danger Zone"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Dark Mode"
|
||||
@@ -3539,6 +3534,8 @@ msgstr "Delegate Document Ownership"
|
||||
msgid "delete"
|
||||
msgstr "delete"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3588,6 +3585,10 @@ msgstr "delete {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "delete {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "delete {organisationName}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "delete {teamName}"
|
||||
@@ -3640,6 +3641,8 @@ msgstr "Delete Envelope"
|
||||
msgid "Delete Folder"
|
||||
msgstr "Delete Folder"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Delete organisation"
|
||||
@@ -3702,6 +3705,10 @@ msgstr "Deleted"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Deleting account..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "Deletion scheduled"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Destination"
|
||||
@@ -3996,6 +4003,12 @@ msgstr "Document completed email"
|
||||
msgid "Document Completed!"
|
||||
msgstr "Document Completed!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Document created"
|
||||
@@ -4437,16 +4450,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Drafted Documents"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Drag & drop your PDF here."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "Drag & drop your document here."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Drag and drop or click to upload"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Drag and drop your PDF file here"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "Drag and drop your document here"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4746,6 +4759,10 @@ msgstr "Email sent!"
|
||||
msgid "Email Settings"
|
||||
msgstr "Email Settings"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "Email the organisation owner to notify them of the deletion."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "Email the owner when a document is created from a direct template"
|
||||
@@ -6129,6 +6146,10 @@ msgstr "Invoice"
|
||||
msgid "IP Address"
|
||||
msgstr "IP Address"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "Irreversible actions for this organisation"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "Issuer URL"
|
||||
@@ -7312,6 +7333,10 @@ msgstr "Or continue with"
|
||||
msgid "Organisation"
|
||||
msgstr "Organisation"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "Organisation \"{organisationName}\" has been deleted"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Organisation Admin"
|
||||
@@ -7677,6 +7702,10 @@ msgstr "per month"
|
||||
msgid "per year"
|
||||
msgstr "per year"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -10372,6 +10401,10 @@ msgstr "The content to show in the banner, HTML is allowed"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "The default email to use when sending emails to recipients"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10497,6 +10530,14 @@ msgstr "The folder you are trying to move the template to does not exist."
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "The following errors occurred:"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "The following recipients require an email address:"
|
||||
@@ -10535,6 +10576,10 @@ msgstr "The organisation group you are looking for may have been removed, rename
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "The organisation role that will be applied to all members in this group."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10923,6 +10968,12 @@ msgstr "This feature is not available on your current plan"
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "This file type isn't supported. Please upload a PDF or Word document."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
@@ -12194,6 +12245,12 @@ msgstr "We are unable to remove this passkey at the moment. Please try again lat
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "We are unable to update this passkey at the moment. Please try again later."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "We couldn't create a Stripe customer. Please try again."
|
||||
@@ -12220,6 +12277,10 @@ msgstr "We couldn't update the organisation. Please try again."
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "We couldn't update the provider. Please try again."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12723,6 +12784,10 @@ msgstr "You are about to delete <0>\"{title}\"</0>"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
@@ -13205,6 +13270,7 @@ msgstr "You have verified your email address for <0>{0}</0>."
|
||||
msgid "You moved the document to team"
|
||||
msgstr "You moved the document to team"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13650,6 +13716,11 @@ msgstr "Your new password cannot be the same as your old password."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "Your organisation has been created."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "Your organisation has been deleted"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "Your organisation has been successfully deleted."
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: es\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-11 09:07\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -630,16 +630,6 @@ msgstr "{validationLength, plural, one {Seleccione exactamente # opción} other
|
||||
msgid "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
msgstr "{visibleRows, plural, one {Mostrando # resultado.} other {Mostrando # resultados.}}"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
msgstr "/* Escribe CSS dirigido a tus páginas de firma. Los selectores se delimitan automáticamente. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
|
||||
#. placeholder {0}: document.title
|
||||
#. placeholder {0}: envelope.title
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
@@ -1631,6 +1621,7 @@ msgstr "Un correo electrónico que contiene una invitación se enviará a cada m
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "Ya existe un correo electrónico con esta dirección."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2398,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "¿No puedes encontrar a alguien?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2881,6 +2873,7 @@ msgstr "Configura cuándo y con qué frecuencia se envían correos electrónicos
|
||||
msgid "Confirm"
|
||||
msgstr "Confirmar"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3410,6 +3403,10 @@ msgstr "Grupos de Organización Personalizados"
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "Personaliza los colores utilizados en tus páginas de firma."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "Zona de peligro"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Modo Oscuro"
|
||||
@@ -3542,6 +3539,8 @@ msgstr "Delegar la propiedad del documento"
|
||||
msgid "delete"
|
||||
msgstr "eliminar"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3591,6 +3590,10 @@ msgstr "eliminar {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "eliminar {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "eliminar {organisationName}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "eliminar {teamName}"
|
||||
@@ -3643,6 +3646,8 @@ msgstr "Eliminar sobre"
|
||||
msgid "Delete Folder"
|
||||
msgstr "Eliminar Carpeta"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Eliminar organización"
|
||||
@@ -3705,6 +3710,10 @@ msgstr "Eliminado"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Eliminando cuenta..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "Eliminación programada"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Destino"
|
||||
@@ -3999,6 +4008,12 @@ msgstr "Correo electrónico de documento completado"
|
||||
msgid "Document Completed!"
|
||||
msgstr "¡Documento completado!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "La conversión de documentos no está disponible temporalmente. Vuelve a intentarlo en unos momentos o sube un PDF."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Documento creado"
|
||||
@@ -4440,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Documentos redactados"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Arrastre y suelte su PDF aquí."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "Arrastra y suelta tu documento aquí."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Arrastra y suelta o haz clic para cargar"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Arrastra y suelta tu archivo PDF aquí"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "Arrastra y suelta tu documento aquí"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4749,6 +4764,10 @@ msgstr "¡Correo electrónico enviado!"
|
||||
msgid "Email Settings"
|
||||
msgstr "Configuración de Correo Electrónico"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "Envía un correo electrónico al propietario de la organización para informarle de la eliminación."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "Enviar un correo electrónico al propietario cuando se cree un documento a partir de una plantilla directa"
|
||||
@@ -6132,6 +6151,10 @@ msgstr "Factura"
|
||||
msgid "IP Address"
|
||||
msgstr "Dirección IP"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "Acciones irreversibles para esta organización"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "URL de emisor"
|
||||
@@ -7315,6 +7338,10 @@ msgstr "O continúa con"
|
||||
msgid "Organisation"
|
||||
msgstr "Organización"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "La organización \"{organisationName}\" ha sido eliminada"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Administrador de Organización"
|
||||
@@ -7680,6 +7707,10 @@ msgstr "al mes"
|
||||
msgid "per year"
|
||||
msgstr "al año"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "Elimina permanentemente esta organización. Los documentos quedarán huérfanos (no se eliminarán) para que sigan siendo accesibles a través de la cuenta de servicio de cuenta eliminada."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -10375,6 +10406,10 @@ msgstr "El contenido que se mostrará en el banner, se permite HTML"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "Correo predeterminado para usar al enviar correos a los destinatarios"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "La eliminación se ejecutará en segundo plano y puede tardar hasta unos minutos en completarse. No vuelvas a ejecutar esta eliminación."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10500,6 +10535,14 @@ msgstr "La carpeta a la que intenta mover la plantilla no existe."
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "Se produjeron los siguientes errores:"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "La siguiente organización ha sido eliminada por un administrador. Tú y tus miembros ya no podrán acceder a esta organización, a sus equipos ni a sus datos asociados."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "La siguiente organización ha sido eliminada. Tú y tus miembros ya no podrán acceder a esta organización, a sus equipos ni a sus datos asociados."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "Los siguientes destinatarios requieren una dirección de correo electrónico:"
|
||||
@@ -10538,6 +10581,10 @@ msgstr "El grupo de organizaciones que estás buscando puede haber sido eliminad
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "El rol de organización que se aplicará a todos los miembros de este grupo."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "La organización se eliminará en segundo plano. Los documentos quedarán huérfanos, no se eliminarán."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10926,6 +10973,12 @@ msgstr "Esta función no está disponible en tu plan actual"
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "Este campo no se puede modificar ni eliminar. Cuando comparta el enlace directo de esta plantilla o lo agregue a su perfil público, cualquiera que acceda podrá ingresar su nombre y correo electrónico, y completar los campos que se le hayan asignado."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "Este tipo de archivo no es compatible. Sube un documento PDF o Word."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "Esta carpeta contiene múltiples elementos. Eliminarla eliminará todas las subcarpetas y moverá todos los documentos y plantillas anidados a la carpeta raíz."
|
||||
@@ -12197,6 +12250,12 @@ msgstr "No podemos eliminar esta clave de acceso en este momento. Por favor, int
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "No podemos actualizar esta clave de acceso en este momento. Por favor, inténtalo de nuevo más tarde."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "No hemos podido convertir este archivo. Comprueba que sea un documento de Word válido o sube un PDF en su lugar."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "No pudimos crear un cliente de Stripe. Por favor, intente nuevamente."
|
||||
@@ -12223,6 +12282,10 @@ msgstr "No pudimos actualizar la organización. Por favor, intente nuevamente."
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "No se pudo actualizar al proveedor. Por favor, inténtelo de nuevo."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "Hemos encontrado un error al intentar eliminar esta organización. Vuelve a intentarlo más tarde."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12726,6 +12789,10 @@ msgstr "Estás a punto de eliminar <0>\"{title}\"</0>"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "Estás a punto de eliminar <0>{0}</0>. Todos los datos relacionados con esta organización, como equipos, documentos y todos los demás recursos serán eliminados. Esta acción es irreversible."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "Estás a punto de eliminar <0>{organisationName}</0>. Esta acción no se puede deshacer. Todos los equipos se eliminarán y todos los documentos quedarán asignados a la cuenta de servicio de cuenta eliminada."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "Estás a punto de eliminar el siguiente correo electrónico del equipo de <0>{teamName}</0>."
|
||||
@@ -13208,6 +13275,7 @@ msgstr "Has verificado tu dirección de correo electrónico para <0>{0}</0>."
|
||||
msgid "You moved the document to team"
|
||||
msgstr "Has movido el documento al equipo"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13653,6 +13721,11 @@ msgstr "Tu nueva contraseña no puede ser la misma que tu antigua contraseña."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "Tu organización ha sido creada."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "Tu organización ha sido eliminada"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "Tu organización ha sido eliminada exitosamente."
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: fr\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-11 09:07\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
@@ -43,7 +43,7 @@ msgstr "« {documentName} » a été signé"
|
||||
|
||||
#: packages/email/template-components/template-document-completed.tsx
|
||||
msgid "“{documentName}” was signed by all signers"
|
||||
msgstr "« {documentName} » a été signé par tous les signataires"
|
||||
msgstr "“{documentName}” a été signé par tous les signataires"
|
||||
|
||||
#: apps/remix/app/components/forms/document-preferences-form.tsx
|
||||
msgid "\"{placeholderEmail}\" on behalf of \"Team Name\" has invited you to sign \"example document\"."
|
||||
@@ -630,16 +630,6 @@ msgstr "{validationLength, plural, one {Sélectionnez exactement # option} other
|
||||
msgid "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
msgstr "{visibleRows, plural, one {Affichage de # résultat.} other {Affichage de # résultats.}}"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
msgstr "/* Écrivez du CSS pour cibler vos pages de signature. Les sélecteurs sont automatiquement limités à leur portée. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
|
||||
#. placeholder {0}: document.title
|
||||
#. placeholder {0}: envelope.title
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
@@ -1631,6 +1621,7 @@ msgstr "Un e-mail contenant une invitation sera envoyé à chaque membre."
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "Un email avec cette adresse existe déjà."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2398,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "Vous ne trouvez pas quelqu’un ?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2881,6 +2873,7 @@ msgstr "Configurer quand et à quelle fréquence des e-mails de rappel sont envo
|
||||
msgid "Confirm"
|
||||
msgstr "Confirmer"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3410,6 +3403,10 @@ msgstr "Groupes d'organisation personnalisés"
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "Personnalisez les couleurs utilisées sur vos pages de signature."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "Zone de danger"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Mode sombre"
|
||||
@@ -3542,6 +3539,8 @@ msgstr "Déléguer la propriété du document"
|
||||
msgid "delete"
|
||||
msgstr "supprimer"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3591,6 +3590,10 @@ msgstr "supprimer {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "supprimer {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "supprimer {organisationName}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "supprimer {teamName}"
|
||||
@@ -3643,6 +3646,8 @@ msgstr "Supprimer l’enveloppe"
|
||||
msgid "Delete Folder"
|
||||
msgstr "Supprimer le Dossier"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Supprimer l'organisation"
|
||||
@@ -3705,6 +3710,10 @@ msgstr "Supprimé"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Suppression du compte..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "Suppression planifiée"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Destination"
|
||||
@@ -3999,6 +4008,12 @@ msgstr "E-mail de document complété"
|
||||
msgid "Document Completed!"
|
||||
msgstr "Document Complété !"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "La conversion de documents est temporairement indisponible. Veuillez réessayer dans quelques instants ou téléverser un PDF."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Document créé"
|
||||
@@ -4440,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Documents brouillon"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Faites glisser et déposez votre PDF ici."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "Faites glisser et déposez votre document ici."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Glissez-déposez ou cliquez pour importer"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Faites glisser et déposez votre fichier PDF ici"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "Faites glisser et déposez votre document ici"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4749,6 +4764,10 @@ msgstr "Email envoyé !"
|
||||
msgid "Email Settings"
|
||||
msgstr "Paramètres de l'email"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "Envoyez un e-mail au propriétaire de l’organisation pour l’informer de la suppression."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "Envoyer un e-mail au propriétaire lorsqu’un document est créé à partir d’un modèle direct"
|
||||
@@ -6132,6 +6151,10 @@ msgstr "Facture"
|
||||
msgid "IP Address"
|
||||
msgstr "Adresse IP"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "Actions irréversibles pour cette organisation"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "URL de l'émetteur"
|
||||
@@ -7315,6 +7338,10 @@ msgstr "Ou continuez avec"
|
||||
msgid "Organisation"
|
||||
msgstr "Organisation"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "L’organisation \"{organisationName}\" a été supprimée"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Administrateur de l'organisation"
|
||||
@@ -7680,6 +7707,10 @@ msgstr "par mois"
|
||||
msgid "per year"
|
||||
msgstr "par an"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "Supprimez définitivement cette organisation. Les documents seront orphelins (non supprimés) afin de rester accessibles via le compte de service de compte supprimé."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -10375,6 +10406,10 @@ msgstr "Le contenu à afficher dans la bannière, le HTML est autorisé"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "L'e-mail par défaut à utiliser lors de l'envoi de courriels aux destinataires"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "La suppression s’exécutera en arrière-plan et peut prendre quelques minutes pour se terminer. Ne relancez pas cette suppression."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10500,6 +10535,14 @@ msgstr "Le dossier vers lequel vous essayez de déplacer le modèle n'existe pas
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "Les erreurs suivantes se sont produites :"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "L’organisation suivante a été supprimée par un administrateur. Vous et vos membres ne pourrez plus accéder à cette organisation, à ses équipes ni aux données qui y sont associées."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "L’organisation suivante a été supprimée. Vous et vos membres ne pourrez plus accéder à cette organisation, à ses équipes ni aux données qui y sont associées."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "Les destinataires suivants doivent avoir une adresse e-mail :"
|
||||
@@ -10538,6 +10581,10 @@ msgstr "Le groupe d'organisation que vous recherchez a peut-être été supprim
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "Le rôle d'organisation qui sera appliqué à tous les membres de ce groupe."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "L’organisation sera supprimée en arrière-plan. Les documents seront rendus orphelins, et non supprimés."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10926,6 +10973,12 @@ msgstr "Cette fonctionnalité n’est pas disponible avec votre offre actuelle"
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "Ce champ ne peut pas être modifié ou supprimé. Lorsque vous partagez le lien direct de ce modèle ou l'ajoutez à votre profil public, toute personne qui y accède peut saisir son nom et son email, et remplir les champs qui lui sont attribués."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "Ce type de fichier n’est pas pris en charge. Veuillez téléverser un document PDF ou Word."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "Ce dossier contient plusieurs éléments. Le supprimer supprimera tous les sous-dossiers et déplacera tous les documents et modèles intégrés vers le dossier racine."
|
||||
@@ -12197,6 +12250,12 @@ msgstr "Nous ne pouvons pas supprimer cette clé de passkey pour le moment. Veui
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "Nous ne pouvons pas mettre à jour cette clé de passkey pour le moment. Veuillez réessayer plus tard."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "Nous n’avons pas pu convertir ce fichier. Veuillez vérifier qu’il s’agit d’un document Word valide ou téléverser un fichier PDF à la place."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "Nous n'avons pas pu créer un client Stripe. Veuillez réessayer."
|
||||
@@ -12223,6 +12282,10 @@ msgstr "Nous n'avons pas pu mettre à jour l'organisation. Veuillez réessayer."
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "Nous n'avons pas pu mettre à jour le fournisseur. Veuillez réessayer."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "Nous avons rencontré une erreur lors de la tentative de suppression de cette organisation. Veuillez réessayer plus tard."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12726,6 +12789,10 @@ msgstr "Vous êtes sur le point de supprimer <0>\"{title}\"</0>"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "Vous êtes sur le point de supprimer <0>{0}</0>. Toutes les données relatives à cette organisation, telles que les équipes, les documents et toutes les autres ressources, seront supprimées. Cette action est irréversible."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "Vous êtes sur le point de supprimer <0>{organisationName}</0>. Cette action est irréversible. Toutes les équipes seront supprimées et tous les documents seront rattachés de manière orpheline au compte de service du compte supprimé."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "Vous êtes sur le point de supprimer l'e-mail d'équipe suivant de <0>{teamName}</0>."
|
||||
@@ -13208,6 +13275,7 @@ msgstr "Vous avez vérifié votre adresse e-mail pour <0>{0}</0>."
|
||||
msgid "You moved the document to team"
|
||||
msgstr "Vous avez déplacé le document vers l'équipe"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13653,6 +13721,11 @@ msgstr "Ton nouveau mot de passe ne peut pas être le même que l'ancien."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "Votre organisation a été créée."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "Votre organisation a été supprimée"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "Votre organisation a été supprimée avec succès."
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: it\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-11 09:07\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -630,16 +630,6 @@ msgstr "{validationLength, plural, one {Seleziona esattamente # opzione} other {
|
||||
msgid "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
msgstr "{visibleRows, plural, one {Visualizzazione di # risultato.} other {Visualizzazione di # risultati.}}"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
msgstr "/* Scrivi il CSS per le tue pagine di firma. I selettori sono applicati automaticamente solo a queste pagine. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
|
||||
#. placeholder {0}: document.title
|
||||
#. placeholder {0}: envelope.title
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
@@ -1631,6 +1621,7 @@ msgstr "Verrà inviato un'email contenente un invito a ciascun membro."
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "Una email con questo indirizzo esiste già."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2398,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "Non riesci a trovare qualcuno?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2881,6 +2873,7 @@ msgstr "Configura quando e con quale frequenza vengono inviate le email di prome
|
||||
msgid "Confirm"
|
||||
msgstr "Conferma"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3410,6 +3403,10 @@ msgstr "Gruppi di Organizzazione Personalizzati"
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "Personalizza i colori utilizzati nelle tue pagine di firma."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "Zona pericolosa"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Modalità Scura"
|
||||
@@ -3542,6 +3539,8 @@ msgstr "Delega della proprietà del documento"
|
||||
msgid "delete"
|
||||
msgstr "elimina"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3591,6 +3590,10 @@ msgstr "elimina {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "elimina {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "Elimina {organisationName}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "elimina {teamName}"
|
||||
@@ -3643,6 +3646,8 @@ msgstr "Elimina busta"
|
||||
msgid "Delete Folder"
|
||||
msgstr "Elimina Cartella"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Elimina organizzazione"
|
||||
@@ -3705,6 +3710,10 @@ msgstr "Eliminato"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Eliminazione account..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "Eliminazione pianificata"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Destinazione"
|
||||
@@ -3999,6 +4008,12 @@ msgstr "Email documento completato"
|
||||
msgid "Document Completed!"
|
||||
msgstr "Documento completato!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "La conversione dei documenti è temporaneamente non disponibile. Riprova tra poco oppure carica un PDF."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Documento creato"
|
||||
@@ -4440,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Documenti redatti"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Trascina e rilascia il tuo PDF qui."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "Trascina e rilascia qui il tuo documento."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Trascina e rilascia o fai clic per caricare"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Trascina qui il tuo file PDF"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "Trascina e rilascia qui il tuo documento"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4749,6 +4764,10 @@ msgstr "Email inviato!"
|
||||
msgid "Email Settings"
|
||||
msgstr "Impostazioni Email"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "Invia un'email al proprietario dell'organizzazione per informarlo dell'eliminazione."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "Invia un’email al proprietario quando un documento viene creato da un modello diretto"
|
||||
@@ -6132,6 +6151,10 @@ msgstr "Fattura"
|
||||
msgid "IP Address"
|
||||
msgstr "Indirizzo IP"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "Azioni irreversibili per questa organizzazione"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "URL Emittente"
|
||||
@@ -7315,6 +7338,10 @@ msgstr "Oppure continua con"
|
||||
msgid "Organisation"
|
||||
msgstr "Organizzazione"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "L'organizzazione \"{organisationName}\" è stata eliminata"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Amministratore dell'organizzazione"
|
||||
@@ -7680,6 +7707,10 @@ msgstr "al mese"
|
||||
msgid "per year"
|
||||
msgstr "all'anno"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "Elimina definitivamente questa organizzazione. I documenti verranno orfani (non eliminati), così da rimanere accessibili tramite l'account di servizio dell'account eliminato."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -10375,6 +10406,10 @@ msgstr "Il contenuto da mostrare nel banner, HTML è consentito"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "L'email predefinita da utilizzare quando si inviano email ai destinatari"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "L'eliminazione verrà eseguita in background e potrebbe richiedere alcuni minuti per essere completata. Non eseguire nuovamente questa eliminazione."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10500,6 +10535,14 @@ msgstr "La cartella in cui stai cercando di spostare il modello non esiste."
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "Si sono verificati i seguenti errori:"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "La seguente organizzazione è stata eliminata da un amministratore. Tu e i tuoi membri non potrete più accedere a questa organizzazione, ai suoi team o ai relativi dati."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "La seguente organizzazione è stata eliminata. Tu e i tuoi membri non potrete più accedere a questa organizzazione, ai suoi team o ai relativi dati."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "I seguenti destinatari richiedono un indirizzo email:"
|
||||
@@ -10538,6 +10581,10 @@ msgstr "Il gruppo organizzativo che stai cercando potrebbe essere stato rimosso,
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "Il ruolo organizzativo che verrà applicato a tutti i membri in questo gruppo."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "L'organizzazione verrà eliminata in background. I documenti verranno orfani, non eliminati."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10926,6 +10973,12 @@ msgstr "Questa funzionalità non è disponibile nel tuo piano attuale"
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "Questo campo non può essere modificato o eliminato. Quando condividi il link diretto di questo modello o lo aggiungi al tuo profilo pubblico, chiunque vi acceda può inserire il proprio nome e email, e compilare i campi assegnati."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "Questo tipo di file non è supportato. Carica un documento PDF o Word."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "Questa cartella contiene più elementi. Eliminandola verranno rimossi tutte le sottocartelle e i documenti e modelli nidificati verranno spostati nella cartella principale."
|
||||
@@ -12197,6 +12250,12 @@ msgstr "Non siamo in grado di rimuovere questa chiave d'accesso al momento. Per
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "Non siamo in grado di aggiornare questa chiave d'accesso al momento. Per favore riprova più tardi."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "Non è stato possibile convertire questo file. Verifica che sia un documento Word valido oppure carica un PDF."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "Non siamo riusciti a creare un cliente di Stripe. Si prega di riprovare."
|
||||
@@ -12223,6 +12282,10 @@ msgstr "Non siamo riusciti ad aggiornare l'organizzazione. Si prega di riprovare
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "Non siamo riusciti ad aggiornare il provider. Per favore riprova."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "Si è verificato un errore durante il tentativo di eliminare questa organizzazione. Riprova più tardi."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12726,6 +12789,10 @@ msgstr "Stai per eliminare <0>\"{title}\"</0>"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "Stai per eliminare <0>{0}</0>. Tutti i dati relativi a questa organizzazione come team, documenti e tutte le altre risorse verranno eliminati. Questa azione è irreversibile."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "Stai per eliminare <0>{organisationName}</0>. Questa azione non è reversibile. Tutti i team verranno rimossi e tutti i documenti verranno assegnati all’account di servizio dell’account eliminato."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "Stai per eliminare la seguente email del team da <0>{teamName}</0>."
|
||||
@@ -13208,6 +13275,7 @@ msgstr "Hai verificato il tuo indirizzo email per <0>{0}</0>."
|
||||
msgid "You moved the document to team"
|
||||
msgstr "Hai spostato il documento al team"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13653,6 +13721,11 @@ msgstr "La tua nuova password non può essere la stessa della vecchia password."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "La tua organizzazione è stata creata."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "La tua organizzazione è stata eliminata"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "La tua organizzazione è stata eliminata correttamente."
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ja\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-11 09:07\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Japanese\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -630,16 +630,6 @@ msgstr "{validationLength, plural, other {ちょうど # 個のオプション
|
||||
msgid "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
msgstr "{visibleRows, plural, other {# 件の結果を表示中}}"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
msgstr "/* 署名ページを対象とする CSS を記述します。セレクタは自動的にスコープされます。 */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
|
||||
#. placeholder {0}: document.title
|
||||
#. placeholder {0}: envelope.title
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
@@ -1631,6 +1621,7 @@ msgstr "各メンバー宛てに招待状を含むメールが送信されます
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "このメールアドレスはすでに存在します。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2398,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "メンバーが見つかりませんか?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2881,6 +2873,7 @@ msgstr "まだ署名を完了していない受信者に、リマインドメー
|
||||
msgid "Confirm"
|
||||
msgstr "確認"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3410,6 +3403,10 @@ msgstr "カスタム組織グループ"
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "署名ページで使用されるカラーをカスタマイズします。"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "危険ゾーン"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "ダークモード"
|
||||
@@ -3542,6 +3539,8 @@ msgstr "文書の所有権を委譲する"
|
||||
msgid "delete"
|
||||
msgstr "delete"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3591,6 +3590,10 @@ msgstr "delete {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "delete {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "{organisationName} を削除"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "delete {teamName}"
|
||||
@@ -3643,6 +3646,8 @@ msgstr "封筒を削除"
|
||||
msgid "Delete Folder"
|
||||
msgstr "フォルダを削除"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "組織を削除"
|
||||
@@ -3705,6 +3710,10 @@ msgstr "削除済み"
|
||||
msgid "Deleting account..."
|
||||
msgstr "アカウントを削除しています..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "削除がスケジュールされました"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "送信先"
|
||||
@@ -3999,6 +4008,12 @@ msgstr "ドキュメント完了メール"
|
||||
msgid "Document Completed!"
|
||||
msgstr "文書が完了しました!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "ドキュメントの変換は現在一時的に利用できません。しばらくしてからもう一度お試しいただくか、PDF をアップロードしてください。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "文書を作成しました"
|
||||
@@ -4440,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "下書き文書"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "PDF をここにドラッグ&ドロップしてください。"
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "ここにドキュメントをドラッグ&ドロップしてください。"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "ドラッグ&ドロップするか、クリックしてアップロード"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "PDF ファイルをここにドラッグ&ドロップしてください"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "ここにドキュメントをドラッグ&ドロップしてください。"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4749,6 +4764,10 @@ msgstr "メールを送信しました!"
|
||||
msgid "Email Settings"
|
||||
msgstr "メール設定"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "組織のオーナーに、この削除について通知するメールを送信します。"
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "ダイレクトテンプレートからドキュメントが作成されたときに、所有者にメールで通知する"
|
||||
@@ -6132,6 +6151,10 @@ msgstr "請求書"
|
||||
msgid "IP Address"
|
||||
msgstr "IP アドレス"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "この組織に対する取り消し不可能な操作"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "Issuer URL"
|
||||
@@ -7315,6 +7338,10 @@ msgstr "または次の方法で続行"
|
||||
msgid "Organisation"
|
||||
msgstr "組織"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "組織 \"{organisationName}\" は削除されました"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "組織管理者"
|
||||
@@ -7680,6 +7707,10 @@ msgstr "月額"
|
||||
msgid "per year"
|
||||
msgstr "年額"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "この組織を完全に削除します。ドキュメントは孤立状態(削除されません)となり、削除済みアカウント用サービスアカウント経由で引き続きアクセス可能です。"
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -10375,6 +10406,10 @@ msgstr "バナーに表示するコンテンツです。HTML が使用できま
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "受信者にメールを送信する際に使用する既定のメールアドレスです"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "削除はバックグラウンドで実行され、完了まで数分かかる場合があります。この削除を再度実行しないでください。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10500,6 +10535,14 @@ msgstr "テンプレートの移動先として指定されたフォルダは存
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "次のエラーが発生しました。"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "次の組織は管理者によって削除されました。あなたとメンバーは、この組織、そのチーム、および関連データに今後アクセスできなくなります。"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "次の組織は削除されました。あなたとメンバーは、この組織、そのチーム、および関連データに今後アクセスできなくなります。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "次の受信者にはメールアドレスが必要です:"
|
||||
@@ -10538,6 +10581,10 @@ msgstr "お探しの組織グループは削除されたか、名前が変更さ
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "このグループ内のすべてのメンバーに適用される組織ロールです。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "組織はバックグラウンドで削除されます。ドキュメントは削除されず、孤立状態になります。"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10926,6 +10973,12 @@ msgstr "この機能は現在ご利用中のプランではご利用いただけ
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "このフィールドは変更も削除もできません。このテンプレートのダイレクトリンクを共有するか、公開プロフィールに追加すると、アクセスした人は自分の名前とメールアドレスを入力し、割り当てられたフィールドに入力できます。"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "このファイル形式はサポートされていません。PDF か Word ドキュメントをアップロードしてください。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "このフォルダには複数のアイテムが含まれています。削除すると、すべてのサブフォルダが削除され、入れ子になったドキュメントとテンプレートはルートフォルダに移動されます。"
|
||||
@@ -12197,6 +12250,12 @@ msgstr "現在、このパスキーは削除できません。後でもう一度
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "現在、このパスキーは更新できません。後でもう一度お試しください。"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "このファイルを変換できませんでした。有効な Word ドキュメントであることを確認するか、代わりに PDF をアップロードしてください。"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "Stripe 顧客を作成できませんでした。もう一度お試しください。"
|
||||
@@ -12223,6 +12282,10 @@ msgstr "組織を更新できませんでした。もう一度お試しくださ
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "プロバイダーを更新できませんでした。もう一度お試しください。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "この組織の削除を試みた際にエラーが発生しました。時間をおいて、もう一度お試しください。"
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12726,6 +12789,10 @@ msgstr "<0>\"{title}\"</0> を削除しようとしています"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "<0>{0}</0> を削除しようとしています。この組織に関連するチーム、ドキュメント、その他すべてのリソースは削除されます。この操作は元に戻せません。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "<0>{organisationName}</0> を削除しようとしています。この操作は元に戻せません。すべてのチームが削除され、すべての文書は削除済みアカウント用サービスアカウントに紐づけられない状態になります。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "<0>{teamName}</0> から次のチームメールアドレスを削除しようとしています。"
|
||||
@@ -13208,6 +13275,7 @@ msgstr "<0>{0}</0> のメールアドレスを確認しました。"
|
||||
msgid "You moved the document to team"
|
||||
msgstr "文書をチームに移動しました"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13653,6 +13721,11 @@ msgstr "新しいパスワードは、以前のパスワードと同じにはで
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "組織が作成されました。"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "あなたの組織は削除されました。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "組織は正常に削除されました。"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ko\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-11 09:07\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Korean\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -630,16 +630,6 @@ msgstr "{validationLength, plural, other {정확히 #개의 옵션을 선택하
|
||||
msgid "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
msgstr "{visibleRows, plural, other {총 #개 결과 중 표시 중입니다.}}"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
msgstr "/* 서명 페이지를 대상으로 하는 CSS를 작성하세요. 선택자는 자동으로 범위가 지정됩니다. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
|
||||
#. placeholder {0}: document.title
|
||||
#. placeholder {0}: envelope.title
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
@@ -1631,6 +1621,7 @@ msgstr "각 구성원에게 초대장이 포함된 이메일이 발송됩니다.
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "이 이메일 주소를 사용하는 이메일이 이미 있습니다."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2398,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "누군가를 찾을 수 없나요?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2881,6 +2873,7 @@ msgstr "아직 서명을 완료하지 않은 수신자에게 알림 이메일을
|
||||
msgid "Confirm"
|
||||
msgstr "확인"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3410,6 +3403,10 @@ msgstr "사용자 지정 조직 그룹"
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "서명 페이지에서 사용되는 색상을 사용자 지정하세요."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "위험 구역"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "다크 모드"
|
||||
@@ -3542,6 +3539,8 @@ msgstr "문서 소유권 위임"
|
||||
msgid "delete"
|
||||
msgstr "delete"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3591,6 +3590,10 @@ msgstr "delete {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "{emailDomain} 삭제"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "{organisationName} 삭제"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "delete {teamName}"
|
||||
@@ -3643,6 +3646,8 @@ msgstr "봉투 삭제"
|
||||
msgid "Delete Folder"
|
||||
msgstr "폴더 삭제"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "조직 삭제"
|
||||
@@ -3705,6 +3710,10 @@ msgstr "삭제됨"
|
||||
msgid "Deleting account..."
|
||||
msgstr "계정 삭제 중..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "삭제가 예약되었습니다"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "대상"
|
||||
@@ -3999,6 +4008,12 @@ msgstr "문서 완료 이메일"
|
||||
msgid "Document Completed!"
|
||||
msgstr "문서가 완료되었습니다!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "문서 변환을 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도하거나 PDF를 업로드해 주세요."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "문서가 생성되었습니다"
|
||||
@@ -4440,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "임시 저장된 문서"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "여기에 PDF를 끌어다 놓으세요."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "문서를 이곳에 드래그 앤 드롭해 주세요."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "드래그 앤 드롭 또는 클릭하여 업로드"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "여기에 PDF 파일을 드래그 앤 드롭하세요."
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "문서를 이곳에 드래그해서 놓아 주세요."
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4749,6 +4764,10 @@ msgstr "이메일이 전송되었습니다!"
|
||||
msgid "Email Settings"
|
||||
msgstr "이메일 설정"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "조직 소유자에게 삭제 사실을 알리는 이메일을 보내세요."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "직접 템플릿에서 문서가 생성되면 소유자에게 이메일 보내기"
|
||||
@@ -6132,6 +6151,10 @@ msgstr "청구서"
|
||||
msgid "IP Address"
|
||||
msgstr "IP 주소"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "이 조직에 대한 되돌릴 수 없는 작업"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "발급자 URL"
|
||||
@@ -7315,6 +7338,10 @@ msgstr "또는 다음으로 계속"
|
||||
msgid "Organisation"
|
||||
msgstr "조직"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "조직 \"{organisationName}\"이(가) 삭제되었습니다"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "조직 관리자"
|
||||
@@ -7680,6 +7707,10 @@ msgstr "월별"
|
||||
msgid "per year"
|
||||
msgstr "연간"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "이 조직을 영구적으로 삭제합니다. 문서는 고아 상태(삭제되지 않음)로 남겨져, 삭제된 계정 서비스 계정을 통해 계속 접근할 수 있습니다."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -10375,6 +10406,10 @@ msgstr "배너에 표시할 콘텐츠입니다. HTML 사용 가능"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "수신자에게 이메일을 보낼 때 사용할 기본 이메일입니다."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "삭제는 백그라운드에서 실행되며 완료까지 몇 분 정도 걸릴 수 있습니다. 이 삭제 작업을 다시 실행하지 마세요."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10500,6 +10535,14 @@ msgstr "템플릿을 이동하려는 폴더가 존재하지 않습니다."
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "다음 오류가 발생했습니다."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "다음 조직이 관리자에 의해 삭제되었습니다. 이제 귀하와 구성원은 이 조직, 팀, 및 관련 데이터에 더 이상 접근할 수 없습니다."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "다음 조직이 삭제되었습니다. 이제 귀하와 구성원은 이 조직, 팀, 및 관련 데이터에 더 이상 접근할 수 없습니다."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "다음 수신자에게는 이메일 주소가 필요합니다:"
|
||||
@@ -10538,6 +10581,10 @@ msgstr "찾고 있는 조직 그룹은 삭제되었거나 이름이 변경되었
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "이 그룹의 모든 구성원에게 적용될 조직 역할입니다."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "이 조직은 백그라운드에서 삭제됩니다. 문서는 고아 상태로 남으며, 삭제되지는 않습니다."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10926,6 +10973,12 @@ msgstr "현재 요금제에서는 이 기능을 이용할 수 없습니다."
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "이 필드는 수정하거나 삭제할 수 없습니다. 이 템플릿의 다이렉트 링크를 공유하거나 공개 프로필에 추가하면, 누구든지 링크에 접근해 이름과 이메일을 입력하고 본인에게 할당된 필드를 작성할 수 있습니다."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "이 파일 형식은 지원되지 않습니다. PDF 또는 Word 문서를 업로드해 주세요."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "이 폴더에는 여러 항목이 포함되어 있습니다. 삭제하면 모든 하위 폴더가 제거되고, 포함된 문서와 템플릿은 루트 폴더로 이동됩니다."
|
||||
@@ -12197,6 +12250,12 @@ msgstr "현재 이 패스키를 제거할 수 없습니다. 나중에 다시 시
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "현재 이 패스키를 업데이트할 수 없습니다. 나중에 다시 시도해 주세요."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "이 파일을 변환할 수 없습니다. 유효한 Word 문서인지 확인하거나 대신 PDF를 업로드해 주세요."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "Stripe 고객을 생성하지 못했습니다. 다시 시도해 주세요."
|
||||
@@ -12223,6 +12282,10 @@ msgstr "조직을 업데이트하지 못했습니다. 다시 시도해 주세요
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "제공자를 업데이트하지 못했습니다. 다시 시도해 주세요."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "이 조직을 삭제하는 동안 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12726,6 +12789,10 @@ msgstr "다음 항목을 삭제하려고 합니다: <0>\"{title}\"</0>"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "곧 <0>{0}</0>을(를) 삭제하려고 합니다. 팀, 문서 및 기타 모든 리소스를 포함해 이 조직과 관련된 모든 데이터가 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "<0>{organisationName}</0>을(를) 삭제하려고 합니다. 이 작업은 되돌릴 수 없습니다. 모든 팀이 삭제되며, 모든 문서는 삭제된 계정 서비스 계정에 고아 상태로 남게 됩니다."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "<0>{teamName}</0>에서 다음 팀 이메일을 삭제하려고 합니다."
|
||||
@@ -13208,6 +13275,7 @@ msgstr "<0>{0}</0> 팀의 이메일 주소를 성공적으로 인증했습니다
|
||||
msgid "You moved the document to team"
|
||||
msgstr "문서를 팀으로 이동했습니다"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13653,6 +13721,11 @@ msgstr "새 비밀번호는 기존 비밀번호와 같을 수 없습니다."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "조직이 생성되었습니다."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "조직이 삭제되었습니다."
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "조직이 성공적으로 삭제되었습니다."
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: nl\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-11 09:07\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -630,16 +630,6 @@ msgstr "{validationLength, plural, one {Selecteer precies # optie} other {Select
|
||||
msgid "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
msgstr "{visibleRows, plural, one {# resultaat wordt getoond.} other {# resultaten worden getoond.}}"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
msgstr "/* Schrijf CSS voor je ondertekenpagina's. Selectors worden automatisch gescopeerd. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
|
||||
#. placeholder {0}: document.title
|
||||
#. placeholder {0}: envelope.title
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
@@ -1631,6 +1621,7 @@ msgstr "Naar elk lid wordt een e‑mail met een uitnodiging gestuurd."
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "Er bestaat al een e-mailadres met dit adres."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2398,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "Kunt u niemand vinden?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2881,6 +2873,7 @@ msgstr "Configureer wanneer en hoe vaak herinneringsmails worden verzonden naar
|
||||
msgid "Confirm"
|
||||
msgstr "Bevestigen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3410,6 +3403,10 @@ msgstr "Aangepaste organisatiegroepen"
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "Pas de kleuren aan die op je ondertekenpagina's worden gebruikt."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "Gevarenzone"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Donker modus"
|
||||
@@ -3542,6 +3539,8 @@ msgstr "Documenteigendom delegeren"
|
||||
msgid "delete"
|
||||
msgstr "verwijderen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3591,6 +3590,10 @@ msgstr "{0} verwijderen"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "verwijder {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "{organisationName} verwijderen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "{teamName} verwijderen"
|
||||
@@ -3643,6 +3646,8 @@ msgstr "Envelope verwijderen"
|
||||
msgid "Delete Folder"
|
||||
msgstr "Map verwijderen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Organisatie verwijderen"
|
||||
@@ -3705,6 +3710,10 @@ msgstr "Verwijderd"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Account wordt verwijderd..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "Verwijdering gepland"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Bestemming"
|
||||
@@ -3999,6 +4008,12 @@ msgstr "E-mail bij voltooid document"
|
||||
msgid "Document Completed!"
|
||||
msgstr "Document voltooid!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "Documentconversie is tijdelijk niet beschikbaar. Probeer het over korte tijd opnieuw of upload een PDF-bestand."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Document aangemaakt"
|
||||
@@ -4440,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Conceptdocumenten"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Sleep je PDF hierheen."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "Sleep je document hierheen."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Slepen en neerzetten of klik om te uploaden"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Sleep je PDF-bestand hierheen"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "Sleep je document hierheen"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4749,6 +4764,10 @@ msgstr "E‑mail verzonden!"
|
||||
msgid "Email Settings"
|
||||
msgstr "E-mailinstellingen"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "Stuur een e-mail naar de eigenaar van de organisatie om hem/haar op de hoogte te stellen van de verwijdering."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "Stuur een e-mail naar de eigenaar wanneer een document wordt aangemaakt vanuit een directe sjabloon"
|
||||
@@ -6132,6 +6151,10 @@ msgstr "Factuur"
|
||||
msgid "IP Address"
|
||||
msgstr "IP‑adres"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "Onomkeerbare acties voor deze organisatie"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "Issuer-URL"
|
||||
@@ -7315,6 +7338,10 @@ msgstr "Of ga verder met"
|
||||
msgid "Organisation"
|
||||
msgstr "Organisatie"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "Organisatie \"{organisationName}\" is verwijderd"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Organisatiebeheerder"
|
||||
@@ -7680,6 +7707,10 @@ msgstr "per maand"
|
||||
msgid "per year"
|
||||
msgstr "per jaar"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "Verwijder deze organisatie permanent. Documenten worden verweesd (niet verwijderd), zodat ze toegankelijk blijven via het serviceaccount voor verwijderde accounts."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -10375,6 +10406,10 @@ msgstr "De inhoud die in de banner wordt weergegeven; HTML is toegestaan"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "Het standaarde-mailadres dat wordt gebruikt bij het verzenden van e-mails naar ontvangers"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "De verwijdering wordt op de achtergrond uitgevoerd en kan enkele minuten duren. Voer deze verwijdering niet opnieuw uit."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10500,6 +10535,14 @@ msgstr "De map waarnaar je de sjabloon probeert te verplaatsen, bestaat niet."
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "De volgende fouten zijn opgetreden:"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "De volgende organisatie is verwijderd door een beheerder. Jij en je leden hebben geen toegang meer tot deze organisatie, de teams of de bijbehorende gegevens."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "De volgende organisatie is verwijderd. Jij en je leden hebben geen toegang meer tot deze organisatie, de teams of de bijbehorende gegevens."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "De volgende ontvangers hebben een e-mailadres nodig:"
|
||||
@@ -10538,6 +10581,10 @@ msgstr "De organisatieronde die u zoekt, is mogelijk verwijderd, hernoemd of hee
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "De organisatierol die aan alle leden in deze groep wordt toegewezen."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "De organisatie wordt op de achtergrond verwijderd. Documenten worden verweesd, niet verwijderd."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10926,6 +10973,12 @@ msgstr "Deze functie is niet beschikbaar in je huidige abonnement."
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "Dit veld kan niet worden gewijzigd of verwijderd. Wanneer je de directe link van deze sjabloon deelt of aan je openbare profiel toevoegt, kan iedereen die toegang heeft zijn naam en e-mailadres invoeren en de aan hem toegewezen velden invullen."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "Dit bestandstype wordt niet ondersteund. Upload een PDF- of Word-document."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "Deze map bevat meerdere items. Verwijderen zorgt ervoor dat alle submappen worden verwijderd en alle geneste documenten en sjablonen naar de hoofdmap worden verplaatst."
|
||||
@@ -12197,6 +12250,12 @@ msgstr "We kunnen deze passkey momenteel niet verwijderen. Probeer het later opn
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "We kunnen deze passkey momenteel niet bijwerken. Probeer het later opnieuw."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "We konden dit bestand niet converteren. Controleer of het een geldig Word-document is of upload in plaats daarvan een PDF-bestand."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "We konden geen Stripe-klant aanmaken. Probeer het opnieuw."
|
||||
@@ -12223,6 +12282,10 @@ msgstr "We konden de organisatie niet bijwerken. Probeer het opnieuw."
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "We konden de provider niet bijwerken. Probeer het opnieuw."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "Er is een fout opgetreden bij het proberen verwijderen van deze organisatie. Probeer het later opnieuw."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12726,6 +12789,10 @@ msgstr "Je staat op het punt om <0>\"{title}\"</0> te verwijderen"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "Je staat op het punt <0>{0}</0> te verwijderen. Alle gegevens die aan deze organisatie zijn gekoppeld, zoals teams, documenten en alle andere resources, worden verwijderd. Deze actie is onomkeerbaar."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "Je staat op het punt om <0>{organisationName}</0> te verwijderen. Deze actie kan niet ongedaan worden gemaakt. Alle teams worden verwijderd en alle documenten worden toegewezen aan het serviceaccount voor verwijderde accounts."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "Je staat op het punt de volgende team‑e‑mail te verwijderen uit <0>{teamName}</0>."
|
||||
@@ -13208,6 +13275,7 @@ msgstr "Je hebt je e‑mailadres voor <0>{0}</0> geverifieerd."
|
||||
msgid "You moved the document to team"
|
||||
msgstr "Je hebt het document naar het team verplaatst"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13653,6 +13721,11 @@ msgstr "Je nieuwe wachtwoord mag niet hetzelfde zijn als je oude wachtwoord."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "Je organisatie is aangemaakt."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "Je organisatie is verwijderd"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "Je organisatie is succesvol verwijderd."
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: pl\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-12 00:33\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\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"
|
||||
@@ -630,16 +630,6 @@ msgstr "{validationLength, plural, one {Wybierz dokładnie # opcję} few {Wybier
|
||||
msgid "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
msgstr "{visibleRows, plural, one {# wynik} few {# wyniki} many {# wyników} other {# wyników}}"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
msgstr "/* Napisz CSS skierowany do swoich stron podpisywania. Selektory są automatycznie ograniczane (scope’owane). */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
|
||||
#. placeholder {0}: document.title
|
||||
#. placeholder {0}: envelope.title
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
@@ -1631,6 +1621,7 @@ msgstr "Wiadomość z zaproszeniem zostanie wysłana do każdego użytkownika."
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "Ten adres e-mail już istnieje."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2398,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "Nie możesz kogoś znaleźć?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2881,6 +2873,7 @@ msgstr "Skonfiguruj, kiedy i jak często przypomnienia o podpisaniu będą wysy
|
||||
msgid "Confirm"
|
||||
msgstr "Potwierdź"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3410,6 +3403,10 @@ msgstr "Niestandardowe grupy w organizacji"
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "Dostosuj kolory używane na stronach podpisywania."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "Strefa zagrożenia"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Tryb ciemny"
|
||||
@@ -3542,6 +3539,8 @@ msgstr "Zmień właściciela dokumentu"
|
||||
msgid "delete"
|
||||
msgstr "usuń"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3591,6 +3590,10 @@ msgstr "usuń {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "usuń {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "usuń {organisationName}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "usuń {teamName}"
|
||||
@@ -3643,6 +3646,8 @@ msgstr "Usuń kopertę"
|
||||
msgid "Delete Folder"
|
||||
msgstr "Usuń folder"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Usuń organizację"
|
||||
@@ -3705,6 +3710,10 @@ msgstr "Usunięto"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Usuwanie konta..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "Usunięcie zaplanowane"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Adres docelowy"
|
||||
@@ -3999,6 +4008,12 @@ msgstr "Wiadomość potwierdzająca zakończenie"
|
||||
msgid "Document Completed!"
|
||||
msgstr "Dokument został zakończony!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "Konwersja dokumentów jest tymczasowo niedostępna. Spróbuj ponownie za chwilę lub prześlij plik PDF."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Dokument został utworzony"
|
||||
@@ -4440,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Szkice dokumentów"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Przeciągnij i upuść plik PDF."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "Przeciągnij i upuść tutaj swój dokument."
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Przeciągnij i upuść lub kliknij, aby przesłać"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Przeciągnij i upuść plik PDF"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "Przeciągnij i upuść tutaj swój dokument"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4749,6 +4764,10 @@ msgstr "Wiadomość została wysłana!"
|
||||
msgid "Email Settings"
|
||||
msgstr "Ustawienia adresu e-mail"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "Wyślij e-mail do właściciela organizacji, aby powiadomić go o usunięciu."
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "Wyślij właścicielowi wiadomość, gdy dokument zostanie utworzony z bezpośredniego szablonu"
|
||||
@@ -6132,6 +6151,10 @@ msgstr "Faktura"
|
||||
msgid "IP Address"
|
||||
msgstr "Adres IP"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "Nieodwracalne działania dla tej organizacji"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "Adres URL wydawcy"
|
||||
@@ -7315,6 +7338,10 @@ msgstr "Alternatywne metody logowania"
|
||||
msgid "Organisation"
|
||||
msgstr "Organizacja"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "Organizacja \"{organisationName}\" została usunięta"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Administrator organizacji"
|
||||
@@ -7680,6 +7707,10 @@ msgstr "miesięcznie"
|
||||
msgid "per year"
|
||||
msgstr "rocznie"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "Trwale usuń tę organizację. Dokumenty staną się osierocone (nieusunięte), aby pozostały dostępne za pośrednictwem konta serwisowego usuniętego konta."
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -10375,6 +10406,10 @@ msgstr "Zawartość do wyświetlenia w banerze. Dozwolone są znaki HTML"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "Domyślny adres e-mail do wysyłania wiadomości do odbiorców"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "Usunięcie zostanie wykonane w tle i może zająć do kilku minut. Nie uruchamiaj ponownie tego procesu usuwania."
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10500,6 +10535,14 @@ msgstr "Folder, do którego próbujesz przenieść szablon, nie istnieje."
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "Wystąpiły następujące błędy:"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "Poniższa organizacja została usunięta przez administratora. Ty i członkowie Twojej organizacji nie będziecie już mieli dostępu do tej organizacji, jej zespołów ani powiązanych z nią danych."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "Poniższa organizacja została usunięta. Ty i członkowie Twojej organizacji nie będziecie już mieli dostępu do tej organizacji, jej zespołów ani powiązanych z nią danych."
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "Następujący odbiorcy wymagają adresu e-mail:"
|
||||
@@ -10538,6 +10581,10 @@ msgstr "Grupa organizacji, której szukasz, mogła zostać usunięta, zmieniona
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "Rola w organizacji, która zostanie zastosowana dla wszystkich użytkowników tej grupy."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "Organizacja zostanie usunięta w tle. Dokumenty staną się osierocone, nieusunięte."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10926,6 +10973,12 @@ msgstr "Funkcja nie jest dostępna w Twoim planie"
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "Pole nie może być modyfikowane ani usuwane. Gdy udostępnisz bezpośredni link do szablonu lub dodasz go do profilu publicznego, każdy, kto uzyska do niego dostęp, będzie mógł wpisać swoją nazwę oraz adres e-mail i wypełnić przypisane pola."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "Ten typ pliku nie jest obsługiwany. Prześlij plik PDF lub dokument programu Word."
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "Folder zawiera wiele elementów. Usunięcie go spowoduje przeniesienie wszystkich dokumentów i szablonów znajdujących się w tym folderze do folderu głównego."
|
||||
@@ -12197,6 +12250,12 @@ msgstr "Nie mogliśmy usunąć klucza dostępu. Spróbuj ponownie później."
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "Nie mogliśmy zaktualizować klucza dostępu. Spróbuj ponownie później."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "Nie udało się przekonwertować tego pliku. Sprawdź, czy jest to prawidłowy dokument programu Word, lub zamiast tego prześlij plik PDF."
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "Nie mogliśmy utworzyć klienta Stripe. Spróbuj ponownie."
|
||||
@@ -12223,6 +12282,10 @@ msgstr "Nie mogliśmy zaktualizować organizacji. Spróbuj ponownie."
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "Nie mogliśmy zaktualizować dostawcy. Spróbuj ponownie."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "Wystąpił błąd podczas próby usunięcia tej organizacji. Spróbuj ponownie później."
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12726,6 +12789,10 @@ msgstr "Zamierzasz usunąć dokument <0>„{title}”</0>"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "Zamierzasz usunąć organizację <0>{0}</0>. Wszystkie dane powiązane z organizacją, jak zespoły, dokumenty zostaną usunięte. Ta akcja jest nieodwracalna."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "Za chwilę usuniesz <0>{organisationName}</0>. Tej operacji nie można cofnąć. Wszystkie zespoły zostaną usunięte, a wszystkie dokumenty zostaną przypisane do konta usługi usuniętego konta."
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "Zamierzasz usunąć następujący adres e-mail zespołu <0>{teamName}</0>."
|
||||
@@ -13208,6 +13275,7 @@ msgstr "Adres e-mail zespołu <0>{0}</0> został zweryfikowany."
|
||||
msgid "You moved the document to team"
|
||||
msgstr "Przeniosłeś dokument do zespołu"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13653,6 +13721,11 @@ msgstr "Nowe hasło nie może być takie samo jak stare hasło."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "Organizacja została utworzona."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "Twoja organizacja została usunięta"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "Organizacja została usunięta."
|
||||
|
||||
@@ -625,14 +625,6 @@ msgstr "{validationLength, plural, one {Selecione exatamente # opção} other {S
|
||||
msgid "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
msgstr "{visibleRows, plural, one {Exibindo # resultado.} other {Exibindo # resultados.}}"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid ""
|
||||
"/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: document.title
|
||||
#. placeholder {0}: envelope.title
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
@@ -1624,6 +1616,7 @@ msgstr "Um e-mail contendo um convite será enviado para cada membro."
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "Um e-mail com este endereço já existe."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2391,6 +2384,7 @@ msgid "Can't find someone?"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2874,6 +2868,7 @@ msgstr ""
|
||||
msgid "Confirm"
|
||||
msgstr "Confirmar"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3403,6 +3398,10 @@ msgstr "Grupos de Organização Personalizados"
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "Modo Escuro"
|
||||
@@ -3535,6 +3534,8 @@ msgstr ""
|
||||
msgid "delete"
|
||||
msgstr "excluir"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3584,6 +3585,10 @@ msgstr "excluir {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "excluir {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "excluir {teamName}"
|
||||
@@ -3636,6 +3641,8 @@ msgstr ""
|
||||
msgid "Delete Folder"
|
||||
msgstr "Excluir Pasta"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "Excluir organização"
|
||||
@@ -3698,6 +3705,10 @@ msgstr "Excluído"
|
||||
msgid "Deleting account..."
|
||||
msgstr "Excluindo conta..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "Destino"
|
||||
@@ -3992,6 +4003,12 @@ msgstr "E-mail de documento concluído"
|
||||
msgid "Document Completed!"
|
||||
msgstr "Documento Concluído!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "Documento criado"
|
||||
@@ -4433,16 +4450,16 @@ msgid "Drafted Documents"
|
||||
msgstr "Documentos em Rascunho"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "Arraste e solte seu PDF aqui."
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "Arraste e solte ou clique para carregar"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "Arraste e solte seu arquivo PDF aqui"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4742,6 +4759,10 @@ msgstr "E-mail enviado!"
|
||||
msgid "Email Settings"
|
||||
msgstr "Configurações de E-mail"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr ""
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr ""
|
||||
@@ -6125,6 +6146,10 @@ msgstr "Fatura"
|
||||
msgid "IP Address"
|
||||
msgstr "Endereço IP"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "URL do Emissor"
|
||||
@@ -7308,6 +7333,10 @@ msgstr "Ou continue com"
|
||||
msgid "Organisation"
|
||||
msgstr "Organização"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "Administrador da Organização"
|
||||
@@ -7673,6 +7702,10 @@ msgstr "por mês"
|
||||
msgid "per year"
|
||||
msgstr "por ano"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -10368,6 +10401,10 @@ msgstr "O conteúdo a ser exibido no banner, HTML é permitido"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "O e-mail padrão a ser usado ao enviar e-mails para destinatários"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10493,6 +10530,14 @@ msgstr "A pasta para a qual você está tentando mover o modelo não existe."
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "Ocorreram os seguintes erros:"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr ""
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "Os seguintes destinatários requerem um endereço de e-mail:"
|
||||
@@ -10531,6 +10576,10 @@ msgstr "O grupo da organização que você está procurando pode ter sido removi
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "A função da organização que será aplicada a todos os membros deste grupo."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10919,6 +10968,12 @@ msgstr ""
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "Este campo não pode ser modificado ou excluído. Quando você compartilha o link direto deste modelo ou o adiciona ao seu perfil público, qualquer pessoa que o acesse pode inserir seu nome e e-mail e preencher os campos atribuídos a ela."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "Esta pasta contém vários itens. Excluí-la removerá todas as subpastas e moverá todos os documentos e modelos aninhados para a pasta raiz."
|
||||
@@ -12190,6 +12245,12 @@ msgstr "Não conseguimos remover esta passkey no momento. Por favor, tente novam
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "Não conseguimos atualizar esta passkey no momento. Por favor, tente novamente mais tarde."
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "Não conseguimos criar um cliente Stripe. Por favor, tente novamente."
|
||||
@@ -12216,6 +12277,10 @@ msgstr "Não conseguimos atualizar a organização. Por favor, tente novamente."
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "Não conseguimos atualizar o provedor. Por favor, tente novamente."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12719,6 +12784,10 @@ msgstr ""
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "Você está prestes a excluir <0>{0}</0>. Todos os dados relacionados a esta organização, como equipes, documentos e todos os outros recursos, serão excluídos. Esta ação é irreversível."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "Você está prestes a excluir o seguinte e-mail de equipe de <0>{teamName}</0>."
|
||||
@@ -13201,6 +13270,7 @@ msgstr "Você verificou seu endereço de e-mail para <0>{0}</0>."
|
||||
msgid "You moved the document to team"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13646,6 +13716,11 @@ msgstr "Sua nova senha não pode ser a mesma que sua senha antiga."
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "Sua organização foi criada."
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr ""
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "Sua organização foi excluída com sucesso."
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: zh\n"
|
||||
"Project-Id-Version: documenso-app\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-05-11 09:07\n"
|
||||
"PO-Revision-Date: 2026-05-13 06:46\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Chinese Simplified\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -630,16 +630,6 @@ msgstr "{validationLength, plural, other {必须正好选择 # 个选项}}"
|
||||
msgid "{visibleRows, plural, one {Showing # result.} other {Showing # results.}}"
|
||||
msgstr "{visibleRows, plural, other {正在显示 # 条结果。}}"
|
||||
|
||||
#: apps/remix/app/components/forms/branding-preferences-form.tsx
|
||||
msgid "/* Write CSS targeting your signing pages. Selectors are scoped automatically. */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
msgstr "/* 为您的签署页面编写 CSS。选择器会自动进行作用域限制。 */\n"
|
||||
".my-button {\n"
|
||||
" background: red;\n"
|
||||
"}"
|
||||
|
||||
#. placeholder {0}: document.title
|
||||
#. placeholder {0}: envelope.title
|
||||
#: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx
|
||||
@@ -1631,6 +1621,7 @@ msgstr "每位成员都会收到一封包含邀请的邮件。"
|
||||
msgid "An email with this address already exists."
|
||||
msgstr "已存在使用该地址的邮箱。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
|
||||
@@ -2398,6 +2389,7 @@ msgid "Can't find someone?"
|
||||
msgstr "找不到某个人?"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
|
||||
@@ -2881,6 +2873,7 @@ msgstr "配置在何时以及多长频率向尚未完成签署的收件人发送
|
||||
msgid "Confirm"
|
||||
msgstr "确认"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
@@ -3410,6 +3403,10 @@ msgstr "自定义组织组"
|
||||
msgid "Customise the colours used on your signing pages."
|
||||
msgstr "自定义用于签署页面的颜色。"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Danger Zone"
|
||||
msgstr "危险区域"
|
||||
|
||||
#: apps/remix/app/components/general/app-command-menu.tsx
|
||||
msgid "Dark Mode"
|
||||
msgstr "深色模式"
|
||||
@@ -3542,6 +3539,8 @@ msgstr "委派文档所有权"
|
||||
msgid "delete"
|
||||
msgstr "delete"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
|
||||
@@ -3591,6 +3590,10 @@ msgstr "delete {0}"
|
||||
msgid "delete {emailDomain}"
|
||||
msgstr "删除 {emailDomain}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "delete {organisationName}"
|
||||
msgstr "删除 {organisationName}"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
msgid "delete {teamName}"
|
||||
msgstr "delete {teamName}"
|
||||
@@ -3643,6 +3646,8 @@ msgstr "删除信封"
|
||||
msgid "Delete Folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.general.tsx
|
||||
msgid "Delete organisation"
|
||||
msgstr "删除组织"
|
||||
@@ -3705,6 +3710,10 @@ msgstr "已删除"
|
||||
msgid "Deleting account..."
|
||||
msgstr "正在删除账号..."
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Deletion scheduled"
|
||||
msgstr "删除已计划"
|
||||
|
||||
#: apps/remix/app/components/general/webhook-logs-sheet.tsx
|
||||
msgid "Destination"
|
||||
msgstr "目标地址"
|
||||
@@ -3999,6 +4008,12 @@ msgstr "文档完成邮件"
|
||||
msgid "Document Completed!"
|
||||
msgstr "文档已完成!"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF."
|
||||
msgstr "文档转换暂时不可用。请稍后重试或上传 PDF 文件。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-use-dialog.tsx
|
||||
msgid "Document created"
|
||||
msgstr "文档已创建"
|
||||
@@ -4440,16 +4455,16 @@ msgid "Drafted Documents"
|
||||
msgstr "草稿文档数量"
|
||||
|
||||
#: packages/ui/primitives/document-dropzone.tsx
|
||||
msgid "Drag & drop your PDF here."
|
||||
msgstr "将 PDF 拖放到此处。"
|
||||
msgid "Drag & drop your document here."
|
||||
msgstr "将您的文档拖放到此处。"
|
||||
|
||||
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
|
||||
msgid "Drag and drop or click to upload"
|
||||
msgstr "拖放或点击以上传"
|
||||
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
msgid "Drag and drop your PDF file here"
|
||||
msgstr "将 PDF 文件拖放到此处"
|
||||
msgid "Drag and drop your document here"
|
||||
msgstr "将您的文档拖放到此处"
|
||||
|
||||
#: packages/lib/constants/document.ts
|
||||
#: packages/ui/primitives/signature-pad/signature-pad.tsx
|
||||
@@ -4749,6 +4764,10 @@ msgstr "邮件已发送!"
|
||||
msgid "Email Settings"
|
||||
msgstr "邮件设置"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "Email the organisation owner to notify them of the deletion."
|
||||
msgstr "向该组织所有者发送电子邮件,通知其此删除操作。"
|
||||
|
||||
#: packages/ui/components/document/document-email-checkboxes.tsx
|
||||
msgid "Email the owner when a document is created from a direct template"
|
||||
msgstr "当通过直接模板创建文档时向所有者发送电子邮件"
|
||||
@@ -6132,6 +6151,10 @@ msgstr "发票"
|
||||
msgid "IP Address"
|
||||
msgstr "IP 地址"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Irreversible actions for this organisation"
|
||||
msgstr "对此组织执行的不可逆操作"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
|
||||
msgid "Issuer URL"
|
||||
msgstr "签发者 URL"
|
||||
@@ -7315,6 +7338,10 @@ msgstr "或者使用以下方式继续"
|
||||
msgid "Organisation"
|
||||
msgstr "组织"
|
||||
|
||||
#: packages/lib/server-only/organisation/delete-organisation-email.ts
|
||||
msgid "Organisation \"{organisationName}\" has been deleted"
|
||||
msgstr "组织 \"{organisationName}\" 已被删除"
|
||||
|
||||
#: packages/lib/constants/organisations-translations.ts
|
||||
msgid "Organisation Admin"
|
||||
msgstr "组织管理员"
|
||||
@@ -7680,6 +7707,10 @@ msgstr "每月"
|
||||
msgid "per year"
|
||||
msgstr "每年"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account."
|
||||
msgstr "永久删除此组织。文档将被孤立(不会被删除),因此仍可通过已删除账号的服务账户进行访问。"
|
||||
|
||||
#: apps/remix/app/components/tables/user-organisations-table.tsx
|
||||
msgctxt "Personal organisation (adjective)"
|
||||
msgid "Personal"
|
||||
@@ -10375,6 +10406,10 @@ msgstr "横幅中要显示的内容,允许使用 HTML"
|
||||
msgid "The default email to use when sending emails to recipients"
|
||||
msgstr "向收件人发送邮件时使用的默认邮箱"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion."
|
||||
msgstr "删除过程将在后台运行,可能需要几分钟才能完成。请不要重新执行此删除操作。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
|
||||
#: apps/remix/app/components/general/template/template-direct-link-badge.tsx
|
||||
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
|
||||
@@ -10500,6 +10535,14 @@ msgstr "您尝试移动模板到的文件夹不存在。"
|
||||
msgid "The following errors occurred:"
|
||||
msgstr "发生以下错误:"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "以下组织已被管理员删除。您和您的成员将无法再访问此组织、其团队或其关联数据。"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data."
|
||||
msgstr "以下组织已被删除。您和您的成员将无法再访问此组织、其团队或其关联数据。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
|
||||
msgid "The following recipients require an email address:"
|
||||
msgstr "以下收件人需要电子邮件地址:"
|
||||
@@ -10538,6 +10581,10 @@ msgstr "您要查找的组织分组可能已被删除、重命名,或从未存
|
||||
msgid "The organisation role that will be applied to all members in this group."
|
||||
msgstr "将应用于此组所有成员的组织角色。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted."
|
||||
msgstr "该组织将会在后台被删除。文档将被孤立,不会被删除。"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/_layout.tsx
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "The organisation you are looking for may have been removed, renamed or may have never existed."
|
||||
@@ -10926,6 +10973,12 @@ msgstr "此功能在您当前的方案中不可用"
|
||||
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
|
||||
msgstr "此字段无法修改或删除。将此模板的直链分享出去或添加到您的公开主页后,任何访问该链接的人都可以输入自己的姓名和邮箱,并填写分配给 TA 的字段。"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "This file type isn't supported. Please upload a PDF or Word document."
|
||||
msgstr "不支持该文件类型。请上传 PDF 或 Word 文档。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/folder-delete-dialog.tsx
|
||||
msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder."
|
||||
msgstr "此文件夹包含多个项目。删除它将移除所有子文件夹,并将其中的所有文档和模板移动到根文件夹。"
|
||||
@@ -12197,6 +12250,12 @@ msgstr "目前无法移除此通行密钥。请稍后再试。"
|
||||
msgid "We are unable to update this passkey at the moment. Please try again later."
|
||||
msgstr "目前无法更新此通行密钥。请稍后再试。"
|
||||
|
||||
#: apps/remix/app/components/general/document/document-upload-button-legacy.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx
|
||||
#: apps/remix/app/components/general/envelope/envelope-upload-button.tsx
|
||||
msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead."
|
||||
msgstr "我们无法转换此文件。请检查它是否为有效的 Word 文档,或改为上传 PDF。"
|
||||
|
||||
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
|
||||
msgid "We couldn't create a Stripe customer. Please try again."
|
||||
msgstr "我们无法创建 Stripe 客户。请重试。"
|
||||
@@ -12223,6 +12282,10 @@ msgstr "我们无法更新该组织。请重试。"
|
||||
msgid "We couldn't update the provider. Please try again."
|
||||
msgstr "我们未能更新该提供方。请重试。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "We encountered an error while attempting to delete this organisation. Please try again later."
|
||||
msgstr "尝试删除此组织时遇到错误。请稍后重试。"
|
||||
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
#: packages/lib/client-only/providers/envelope-editor-provider.tsx
|
||||
@@ -12726,6 +12789,10 @@ msgstr "您将要删除<0>\"{title}\"</0>"
|
||||
msgid "You are about to delete <0>{0}</0>. All data related to this organisation such as teams, documents, and all other resources will be deleted. This action is irreversible."
|
||||
msgstr "您即将删除 <0>{0}</0>。与该组织相关的所有数据(例如团队、文档及其他所有资源)都将被删除。此操作不可逆。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
msgid "You are about to delete <0>{organisationName}</0>. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account."
|
||||
msgstr "你即将删除 <0>{organisationName}</0>。此操作无法撤销。所有团队都将被移除,且所有文档都会被转移到已删除账号的服务账户中。"
|
||||
|
||||
#: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx
|
||||
msgid "You are about to delete the following team email from <0>{teamName}</0>."
|
||||
msgstr "你即将从 <0>{teamName}</0> 中删除以下团队邮箱。"
|
||||
@@ -13208,6 +13275,7 @@ msgstr "你已验证在 <0>{0}</0> 的邮箱地址。"
|
||||
msgid "You moved the document to team"
|
||||
msgstr "你已将此文档移动到团队"
|
||||
|
||||
#: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/team-delete-dialog.tsx
|
||||
#: apps/remix/app/components/dialogs/token-delete-dialog.tsx
|
||||
@@ -13653,6 +13721,11 @@ msgstr "新密码不能与旧密码相同。"
|
||||
msgid "Your organisation has been created."
|
||||
msgstr "您的组织已创建。"
|
||||
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
#: packages/email/templates/organisation-delete.tsx
|
||||
msgid "Your organisation has been deleted"
|
||||
msgstr "你的组织已被删除"
|
||||
|
||||
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
|
||||
msgid "Your organisation has been successfully deleted."
|
||||
msgstr "您的组织已成功删除。"
|
||||
|
||||
@@ -20,5 +20,11 @@ export const env = <K extends EnvKey>(variable: K): EnvValue<K> => {
|
||||
return (typeof process !== 'undefined' ? process?.env?.[variable] : undefined) as EnvValue<K>;
|
||||
};
|
||||
|
||||
export const createPublicEnv = () =>
|
||||
Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_')));
|
||||
export const createPublicEnv = () => ({
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_'))),
|
||||
// Derived from the private URL so the public flag cannot drift from the
|
||||
// real server-side configuration. Placed last so it wins over any literal
|
||||
// env var with the same name.
|
||||
// The `? 'true' : 'false'` might seem dumb but it's because we're expecting env var strings.
|
||||
NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED: process.env.NEXT_PRIVATE_DOCUMENT_CONVERSION_URL ? 'true' : 'false',
|
||||
});
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* From an unknown string, parse it into an integer array.
|
||||
*
|
||||
* Filter out unknown values.
|
||||
*/
|
||||
export const parseToIntegerArray = (value: unknown): number[] => {
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
@@ -14,6 +9,30 @@ export const parseToIntegerArray = (value: unknown): number[] => {
|
||||
.filter((value) => !isNaN(value));
|
||||
};
|
||||
|
||||
export const parseToStringArray = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const parseCommaSeparatedValues = (value: unknown): string[] | undefined => {
|
||||
const parsed = parseToStringArray(value);
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
};
|
||||
|
||||
export const toCommaSeparatedSearchParam = (values: string[]): string | undefined => {
|
||||
return values.length > 0 ? values.join(',') : undefined;
|
||||
};
|
||||
|
||||
type GetRootHrefOptions = {
|
||||
returnEmptyRootString?: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import { ZDeleteOrganisationRequestSchema, ZDeleteOrganisationResponseSchema } from './delete-organisation.types';
|
||||
|
||||
export const deleteOrganisationRoute = adminProcedure
|
||||
.input(ZDeleteOrganisationRequestSchema)
|
||||
.output(ZDeleteOrganisationResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { organisationId, organisationName, sendEmailToOwner } = input;
|
||||
const { user } = ctx;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
sendEmailToOwner,
|
||||
},
|
||||
});
|
||||
|
||||
const organisation = await prisma.organisation.findUnique({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (organisation.name !== organisationName) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Organisation name does not match',
|
||||
});
|
||||
}
|
||||
|
||||
// The deletion itself is offloaded to a background job because orphaning
|
||||
// potentially-large numbers of envelopes can take a while.
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.admin-delete-organisation',
|
||||
payload: {
|
||||
organisationId: organisation.id,
|
||||
sendEmailToOwner,
|
||||
requestedByUserId: user.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDeleteOrganisationRequestSchema = z.object({
|
||||
organisationId: z.string().min(1),
|
||||
/**
|
||||
* The organisation name as typed by the admin in the confirmation dialog.
|
||||
* Must exactly match the persisted organisation's name for the deletion
|
||||
* to proceed.
|
||||
*/
|
||||
organisationName: z.string().min(1),
|
||||
/**
|
||||
* Whether to email the organisation owner notifying them of the deletion.
|
||||
*/
|
||||
sendEmailToOwner: z.boolean(),
|
||||
});
|
||||
|
||||
export const ZDeleteOrganisationResponseSchema = z.void();
|
||||
|
||||
export type TDeleteOrganisationRequest = z.infer<typeof ZDeleteOrganisationRequestSchema>;
|
||||
export type TDeleteOrganisationResponse = z.infer<typeof ZDeleteOrganisationResponseSchema>;
|
||||
@@ -3,6 +3,7 @@ import { createAdminOrganisationRoute } from './create-admin-organisation';
|
||||
import { createStripeCustomerRoute } from './create-stripe-customer';
|
||||
import { createSubscriptionClaimRoute } from './create-subscription-claim';
|
||||
import { deleteDocumentRoute } from './delete-document';
|
||||
import { deleteOrganisationRoute } from './delete-organisation';
|
||||
import { deleteAdminOrganisationMemberRoute } from './delete-organisation-member';
|
||||
import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
|
||||
import { deleteAdminTeamMemberRoute } from './delete-team-member';
|
||||
@@ -41,6 +42,7 @@ export const adminRouter = router({
|
||||
get: getAdminOrganisationRoute,
|
||||
create: createAdminOrganisationRoute,
|
||||
update: updateAdminOrganisationRoute,
|
||||
delete: deleteOrganisationRoute,
|
||||
swapSubscription: swapOrganisationSubscriptionRoute,
|
||||
},
|
||||
organisationMember: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user