Compare commits

..

39 Commits

Author SHA1 Message Date
Ephraim Duncan 8ca8ad907e Merge branch 'main' into feat/external-2fa-codes 2026-05-27 13:45:46 +00:00
Kendry Grullon 9da2db2e67 feat(storage): add native Azure Blob transport (#2871) 2026-05-27 11:58:39 +07:00
Nikhil Shukla 993df7dc21 fix(docs): correct broken internal docs links (#2869) 2026-05-27 12:39:48 +10:00
ザヘド 807d094cf2 fix: email dictated direct template signer (#2810) 2026-05-27 12:30:31 +10:00
Lucas Smith 3cef238f46 chore: add translations (#2854) 2026-05-26 15:40:11 +10:00
David Nguyen 886c40a46b fix: add constraint on name schema (#2866) 2026-05-26 15:39:35 +10:00
David Nguyen b1b82b775c fix: add missing doc page ref (#2865) 2026-05-26 15:18:20 +10:00
Anish Patil 0fe697c26c fix: handle duplicate organization URL update errors gracefully (#2808) 2026-05-26 14:56:23 +10:00
redouanegrib 7c0031679a docs: implement global error handling and troubleshooting matrix (#2784) 2026-05-26 14:55:40 +10:00
Durgesh Shekhawat 6bb0496224 fix: prevent division by zero in progress bar when requiredRecipientFields is empty (#2855) 2026-05-26 14:41:12 +10:00
Ephraim Duncan eedf483957 fix(prisma): stop large-team-seed running on import (#2852) 2026-05-26 14:13:12 +10:00
Abdulazez (Abza) 5421b0d1cc fix: prevent prop array mutation by spreading allRecipients before sort (#2840) 2026-05-26 14:09:54 +10:00
Abdulazez (Abza) fa2c53bd72 fix: prevent React state mutation by spreading envelope.recipients before sort (#2839) 2026-05-26 14:04:48 +10:00
Lucas Smith 6ac67e646c fix: always show captcha (#2860) 2026-05-25 19:56:24 +07:00
github-actions[bot] 6a20fefd7b chore: extract translations (#2806) 2026-05-22 14:41:35 +10:00
Abdulazez (Abza) 43fe558459 fix: prevent crash when removing last dropdown option in removeValue (#2843) 2026-05-22 14:40:40 +10:00
Abdulazez (Abza) 0a6b0452dc fix: handleInitialsFieldClick now returns initialsToInsert instead of initials (#2838) 2026-05-22 14:31:46 +10:00
David Nguyen fec5d55250 fix: move document complete email to a job (#2835) 2026-05-22 14:21:26 +10:00
Abdulazez (Abza) f1b235819e fix: remove duplicate loadingSpinnerGroup.destroy() in DROPDOWN sign (#2841) 2026-05-22 14:19:57 +10:00
Abdulazez (Abza) d0f9f68689 fix: correct reversed comparison in admin organisations table pagination (#2842) 2026-05-22 14:18:42 +10:00
roshboi f93a98e9a5 chore: updated certification status (#2850)
## Description
Updated HIPAA status to compliant
2026-05-21 15:49:41 +10:00
roshboi c0ea4c60e4 fix(docs): correct API example URLs from /documents to /document (#2836)
## Description

Corrected API endpoint path from /api/v2/documents to /api/v2/document

The current example in the docs(/api/v2/documents) returns a 404
NOT_FOUND object.
2026-05-20 18:17:14 +10:00
Ephraim Duncan 2cb4cc29ea feat: allow admins to create users (#2082) 2026-05-19 20:37:03 +10:00
Lucas Smith d9b5f01e21 chore: add translations (#2833) 2026-05-19 16:19:44 +10:00
Lucas Smith bc3acba72c fix: use captcha imperatively (#2832) 2026-05-19 14:38:40 +10:00
Ephraim Duncan 247a0158bd refactor(ui): replace hardcoded colors with semantic tokens (#2749) 2026-05-19 14:19:31 +10:00
Lucas Smith 9e0b567686 chore: deps upgrade (#2831) 2026-05-18 22:25:48 +10:00
David Nguyen 8f6be474a9 fix: improve api logging (#2820) 2026-05-15 13:41:35 +10:00
Ephraim Duncan 8f5bdef384 docs: require English for PRs and issues (#2819) 2026-05-15 12:30:13 +10:00
ephraimduncan f7b3554b2a Merge remote-tracking branch 'origin/main' into pr-2468
# Conflicts:
#	packages/lib/translations/de/web.po
#	packages/lib/translations/en/web.po
#	packages/lib/translations/es/web.po
#	packages/lib/translations/fr/web.po
#	packages/lib/translations/it/web.po
#	packages/lib/translations/ja/web.po
#	packages/lib/translations/ko/web.po
#	packages/lib/translations/nl/web.po
#	packages/lib/translations/pl/web.po
#	packages/lib/translations/pt-BR/web.po
#	packages/lib/translations/zh/web.po
2026-05-14 15:44:01 +00:00
David Nguyen 999942014e chore: update docs for self hosters (#2816) 2026-05-14 15:07:10 +10:00
Tarana 194b2134cc docs: remove leftover Next.js commands and update to Remix-compatible syntax (#2695) 2026-05-14 12:06:59 +10:00
Ephraim Duncan b8df02750b fix: convert DOCX template uploads to PDF (#2807) 2026-05-14 11:59:27 +10:00
ephraimduncan 6ff8cd7cb2 chore: merge main, resolve biome formatting conflicts 2026-05-12 12:20:22 +00:00
ephraimduncan 138d663c25 chore: merge main, resolve biome formatting conflicts
Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
2026-05-12 11:46:11 +00:00
ephraimduncan 9194884fbe test: remove flaky external 2fa auth test 2026-02-11 00:10:10 +00:00
ephraimduncan 9de87ca906 fix: move 2FA reason codes to shared constants to fix client bundle
Importing SIGNING_2FA_VERIFY_REASON_CODES from a server-only module
pulled prisma into the browser bundle, causing "process is not defined"
and breaking all client-side JS hydration.
2026-02-10 13:57:51 +00:00
ephraimduncan 7163800d36 chore: remove .sisyphus planning artifacts 2026-02-10 12:48:54 +00:00
ephraimduncan bd56929db1 refactor(signing-2fa): simplify server-side and UI code for external 2FA
- Extract throwVerificationError helper in verify-signing-two-factor-token.ts
- Extract throwIssuanceDenied helper in issue-signing-two-factor-token.ts
- Eliminate duplicated attemptsRemaining state in UI component
- Use imported SIGNING_2FA_VERIFY_REASON_CODES constants
- Add statusQuery.refetch() after failed verify for single source of truth
- Fix TypeScript control flow with explicit returns after throws
2026-02-10 12:39:13 +00:00
127 changed files with 5898 additions and 2099 deletions
@@ -0,0 +1,289 @@
---
date: 2026-02-02
title: Support For External 2fa Codes
---
## Objective
Enable organizations to enforce a second factor for document signing while keeping delivery fully external (for example customer-owned SMS), with strong recipient/session binding and auditable controls.
## Problem Context
- Many legacy organizations still rely on SMS for second-factor delivery.
- Their users cannot realistically migrate to authenticator apps or passkeys yet.
- Operating first-party SMS infrastructure in Documenso is costly, risky, and outside core scope.
- Customers need an API-first integration path that fits existing notification infrastructure and compliance controls.
## Proposed Solution
Introduce external 2FA codes for signing:
1. A trusted backend service requests a one-time signing token via API.
2. The customer delivers that token to the signer through their own existing channel (for example SMS).
3. The signer enters the token in the signing flow.
4. Documenso validates the submitted token, then issues a short-lived session-bound verification proof.
5. Signature completion is allowed only when the proof is present and valid for that recipient signing session.
## Decisions Captured In Interview
- Enforcement scope: template-level default with per-recipient override.
- Issuer trust boundary: scoped machine API keys with explicit permission.
- Token lifecycle: newest token immediately revokes prior active token for same recipient/document.
- Brute-force control: token-scoped hard attempt cap.
- Security defaults: TTL 10 minutes, max 5 attempts.
- Verification unlock: session-bound proof (not global recipient unlock).
- Issuance contract: idempotent-ish reissue behavior with explicit structured denial reasons.
- Audit privacy: never log token/code material; log identifiers and reason codes only.
- Missing token at signing time: block with actionable state.
- Rollback behavior: feature-flag off for new sessions only.
- Resend/recovery in v1: support-owned reissue guidance only (no signer self-serve trigger).
- Workspace policy controls in v1: no per-workspace TTL/attempt overrides.
- Session proof TTL in v1: 10 minutes.
## Scope
### In Scope
- API endpoint to issue short-lived signing 2FA tokens for eligible recipients.
- Secure storage/verification mechanism (hashed token + expiry + attempt tracking).
- Signing UI step to collect token before signature submission.
- Standard operating flow: token is generated via API and entered by the recipient in the UI.
- Verification endpoint/path integrated into signing completion checks.
- Audit logging for token issuance and verification attempts.
- Template policy defaults with per-recipient override support.
- Session-bound verification proof issuance after successful code validation.
- Feature-flagged rollout controls at workspace/organization scope.
### Out of Scope
- Native SMS sending/providers inside Documenso.
- New authenticator/passkey implementation.
- Cross-channel delivery guarantees (owned by customer infrastructure).
- UI-only token generation as the primary flow in this phase.
- Fully configurable TTL/attempt policy per workspace in v1.
- Customer callback/webhook resend orchestration in v1.
- Signer-triggered self-serve reissue controls in v1.
## Functional Requirements
- Token is recipient-bound and document/session-bound.
- Token cannot be shared across recipients or recipient roles.
- A recipient token only authorizes signature actions for that same recipient identity.
- If the same human is represented by multiple recipient records, each recipient record still requires its own token.
- Token has strict TTL of 10 minutes and single-use semantics.
- Token verification fails on expiry, mismatch, too many attempts, or reuse.
- Endpoint access is restricted to scoped API clients with explicit issuance permission.
- Clear, localized user errors for invalid/expired tokens.
- Max 5 verification attempts per token; on cap reached, token becomes unusable and signer must use a newly issued token.
- Issuing a new token revokes any existing active token for the same recipient/document pair.
- Successful verification creates a short-lived session-bound proof; only that session can complete signature.
- If 2FA is required but no valid token has been issued yet, signing must be blocked with actionable guidance.
## Non-Functional Requirements
- Verification and consumption path must be atomic and race-safe under concurrent requests.
- Error responses must use stable machine-readable reason codes for customer integrations.
- p95 verification latency should remain within existing signing guardrail budget (target: <= 300 ms server-side).
- Security controls and audit logging must not expose token/code values in logs, traces, or analytics payloads.
## Policy Model
- Default requirement is configured at template/workflow level.
- Sender can override requirement per recipient before send.
- Effective policy is materialized on recipient/document at send time to avoid template drift during in-flight signing.
- Feature flag gates enforcement by workspace/organization for rollout and rollback.
## API Contract
### Token Issuance Endpoint
- Auth: scoped API key with dedicated permission (for example `signing_2fa:issue`).
- Input: recipient/document context and optional idempotency metadata.
- Behavior:
- Eligible recipient: always issues a fresh token and revokes prior active token.
- Ineligible/forbidden state: returns structured 4xx with explicit reason code.
- Never returns previously generated plaintext token; token is visible exactly once at issuance.
- Output:
- Plaintext token (single response only).
- Metadata for integration handling (expiresAt, ttlSeconds, attemptLimit, issuedAt).
### Verification Endpoint
- Input: token submission from signing UI bound to current signing session context.
- Behavior:
- Valid token: atomically consumes token and issues session-bound verification proof.
- Invalid token: increments attempts and returns reason code.
- Expired/revoked/consumed/capped: returns denial reason without revealing sensitive internals.
- Output:
- Success: verification state for current session.
- Failure: localized user-safe message + machine reason code.
### Resend/Reissue Behavior (v1)
- No signer-triggered callback/webhook or self-serve reissue endpoint in v1.
- If token is missing/expired/revoked/capped, signer sees actionable guidance to contact sender/support.
- Reissue remains an API-key-initiated operation from trusted customer backend only.
### Suggested Reason Codes
- `TWO_FA_NOT_REQUIRED`
- `TWO_FA_NOT_ISSUED`
- `TWO_FA_TOKEN_INVALID`
- `TWO_FA_TOKEN_EXPIRED`
- `TWO_FA_TOKEN_REVOKED`
- `TWO_FA_TOKEN_CONSUMED`
- `TWO_FA_ATTEMPT_LIMIT_REACHED`
- `TWO_FA_ISSUER_FORBIDDEN`
- `TWO_FA_RECIPIENT_INELIGIBLE`
## Data Model
Create `signing_two_factor_tokens` (name indicative):
- `id`
- `recipientId`
- `documentId`
- `tokenHash`
- `tokenSalt` (or use KDF settings sufficient to avoid raw-secret recovery)
- `expiresAt`
- `consumedAt` nullable
- `revokedAt` nullable
- `attempts` default 0
- `attemptLimit` default 5
- `issuedByApiKeyId` (or actor reference)
- `createdAt`
Optional companion table/entity for session proof:
- `signing_session_2fa_proofs`
- `sessionId`
- `recipientId`
- `documentId`
- `verifiedAt`
- `expiresAt`
Constraints and indexes:
- Index on (`recipientId`, `documentId`, `expiresAt`).
- At most one active token per (`recipientId`, `documentId`) enforced by transactional revoke-on-issue.
- Guard against lost-update on attempts and consume via row lock or atomic update conditions.
## Signing UX
- Insert 2FA code step before signature commit when effective policy requires it.
- UX states:
- Waiting for code input.
- Invalid code (remaining attempts shown where safe).
- Expired/revoked/attempt cap reached with clear next-step copy.
- Not issued yet state with actionable guidance.
- Recovery copy in v1 must direct signer to sender/support (no in-product resend action).
- Localization required for all user-facing errors.
- Accessibility: input labeling, error announcement, keyboard submission, mobile-friendly numeric entry.
- Session-bound proof behavior must be transparent to user (no global unlock across devices/tabs).
## Security Requirements
- Never persist plaintext token; store salted hash only.
- Rate-limit issuance and verification attempts.
- Invalidate previous active token immediately when a new token is issued.
- Emit security/audit events with actor, recipient, document, timestamp, and reason codes.
- Prevent token leakage in logs, telemetry, and error payloads.
- Use constant-time comparison and hardened random token generation.
- Enforce short proof lifetime for verified session to reduce replay window.
- Set proof TTL to 10 minutes in v1.
## Observability And Audit
Emit events for:
- `2fa_token_issued`
- `2fa_token_issue_denied`
- `2fa_token_verify_succeeded`
- `2fa_token_verify_failed`
- `2fa_token_consumed`
- `2fa_token_revoked`
Event fields:
- `workspaceId`, `documentId`, `recipientId`
- `actorType` (api_key, signer_session, system)
- `actorId` (where applicable)
- `reasonCode`
- `ipHash`, `userAgentHash` (if available)
- `timestamp`
Metrics and alerts:
- Issuance success/failure rates.
- Verification success/failure rate split by reason code.
- Attempt-limit-hit rate.
- p95 verification latency.
- Alert on unusual spikes in invalid attempts per recipient/document/workspace.
## Implementation Plan
1. Domain model
- Add signing 2FA token entity/table and session-proof persistence.
2. Token issuance API
- Add authenticated route for scoped API keys; issue fresh token, revoke prior active.
3. Verification logic
- Validate token state, increment attempts atomically, consume on success, mint session proof.
4. Signing flow integration
- Add UI token prompt and backend guard requiring valid session proof.
5. Observability
- Add reason-coded events and dashboards/alerts.
6. Controls
- Add rate limits, attempt cap (5), revoke-on-reissue, and feature flag checks.
7. Testing
- Unit tests for generation/verification edge cases.
- Integration tests for API and signing flow.
- Concurrency tests for double-submit and parallel verification.
## Testing Matrix
- Token issuance for eligible/ineligible recipients.
- Reissue revokes previous token immediately.
- Verification success path creates session-bound proof.
- Verification fails on mismatch, expiry, revoked, consumed, cap reached.
- Attempt counter increments correctly under concurrent requests.
- Signature blocked when proof absent or expired.
- Recipient A token rejected for recipient B (including same human/multiple recipient records).
- Feature flag off: new sessions bypass external 2FA requirement.
- Audit events emitted with expected reason codes and no token material.
## Acceptance Criteria
- External system can request a token for an eligible signer through API.
- Signer cannot complete signing without valid token when policy requires 2FA.
- A token issued for recipient A is always rejected for recipient B, including when both recipients map to the same underlying person.
- Valid token allows signing exactly once within TTL.
- Expired/reused/invalid tokens are rejected with clear errors.
- No Documenso-owned SMS infrastructure is introduced.
- Audit trail captures issuance and verification outcomes.
- Default policy can be set at template level with per-recipient override at send time.
- New token issuance revokes prior active token for same recipient/document.
- Max 5 failed attempts per token is enforced.
- Successful verification unlocks only the active signing session.
- If no token has been issued yet, signer is blocked with actionable guidance.
## Rollout Strategy
- Ship behind feature flag (workspace-level or organization-level).
- Enable first for pilot customers in regulated domains.
- Monitor verification failure rates and support feedback.
- Gradually expand availability once stable.
- Rollback path: disable flag for new sessions only; preserve already verified in-flight sessions.
## Risks and Mitigations
- Brute-force attempts -> enforce attempt caps, lockouts, and rate limits.
- Delivery delays in customer SMS systems -> allow controlled token re-issue.
- Support burden from expiry confusion -> clear UX copy and resend guidance.
- Concurrency race on consume/attempt updates -> use transactional atomic updates and dedicated tests.
- Misconfigured API clients -> explicit permission scopes and structured denial reasons.
- Forensic gaps vs privacy over-collection -> reason-coded audits with hashed network metadata only.
## Open Questions
- None for v1 scope.
- v1.1 exploration candidate: customer-controlled signer-triggered callback/reissue flow with abuse protections.
+4
View File
@@ -9,6 +9,10 @@ If you plan to contribute to Documenso, please take a moment to feel awesome ✨
- Consider the results from the discussion on the issue
- Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
## English only PRs and Issues
Please write all issues, pull requests, and related comments in English so maintainers and the wider contributor community can follow the discussion.
## Taking issues
Before taking an issue, ensure that:
+14 -144
View File
@@ -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
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p)
### Render
#### Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso)
### Koyeb
#### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
## Elestio
#### Elestio
[![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](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:
@@ -12,7 +12,7 @@ import { Callout } from 'fumadocs-ui/components/callout';
| 21 CFR Part 11 | Compliant (Enterprise) |
| SOC 2 | Compliant |
| ISO 27001 | Planned |
| HIPAA | Planned |
| HIPAA | Compliant (Enterprise) |
## 21 CFR Part 11
@@ -97,12 +97,12 @@ Documenso implements digital signatures with the following characteristics:
- **Timestamps**: RFC 3161 timestamps can be applied to signatures
- **Signature visualization**: Signed documents include visual signature representations
For specific implementation details and configuration options, refer to the [signing certificates](/signing-certificates/overview) documentation.
For specific implementation details and configuration options, refer to the [signing certificates](/docs/concepts/signing-certificates) documentation.
Self-hosted deployments can configure their own signing certificates and timestamp authorities to meet specific compliance requirements.
## Related
- [Legal Validity](/compliance/legal-validity) - Legal frameworks for electronic signatures
- [Signing Certificates Overview](/signing-certificates/overview) - Certificate configuration
- [Audit Log](/features/audit-log) - Document activity tracking
- [E-Sign Compliance](/docs/compliance/esign) - Legal frameworks for electronic signatures
- [Signing Certificates](/docs/concepts/signing-certificates) - Certificate configuration
- [Signing Workflow](/docs/concepts/signing-workflow) - Document activity and audit trail
@@ -167,5 +167,5 @@ To enable sequential signing:
## Related
- [Add Recipients](/users/documents/add-recipients) - How to add recipients to a document
- [Field Types](/concepts/field-types) - Learn about the different field types you can assign to recipients
- [Add Recipients](/docs/users/documents/add-recipients) - How to add recipients to a document
- [Field Types](/docs/concepts/field-types) - Learn about the different field types you can assign to recipients
@@ -0,0 +1,45 @@
---
title: Common Errors
description: A comprehensive troubleshooting matrix for Documenso API and Webhook integration errors.
---
This guide provides a comprehensive troubleshooting matrix for the standard error codes returned by the Documenso API. Use this reference to diagnose and resolve integration issues related to envelopes, recipients, and webhooks.
## Application Error Codes
| Error Code | Description | Recommended Action |
| :--- | :--- | :--- |
| `ALREADY_EXISTS` | The resource you are attempting to create already exists. | Verify if the entity (e.g., user, envelope, webhook) has already been instantiated. Use a `PUT` or `PATCH` request to update the existing resource instead of `POST`. |
| `EXPIRED_CODE` | The provided access code or token has expired. | Generate a new access code or request a new invitation link before retrying the request. |
| `INVALID_BODY` | The request payload is malformed. | Inspect your JSON payload structure. Ensure it strictly adheres to the expected schema and that no required fields are missing. |
| `INVALID_REQUEST` | The overall request is malformed or invalid. | Review your API call parameters, including the URL, query parameters, and headers. Correct the request syntax. |
| `RECIPIENT_EXPIRED` | The signing link or recipient access has expired. | Generate and resend a new invitation to the affected recipient. |
| `LIMIT_EXCEEDED` | Your account usage quota has been exceeded. | Check your current plan limits. Upgrade your subscription or wait until your billing cycle renews. |
| `NOT_FOUND` | The requested resource could not be found (404). | Verify the resource ID (envelope, document, webhook) passed in the URL. Ensure the resource has not been deleted. |
| `NOT_IMPLEMENTED` | The requested feature is not currently supported by the server. | Consult the API documentation to verify available methods. Do not use this endpoint at this time. |
| `NOT_SETUP` | The required configuration for this action is incomplete. | Access your account or integration settings and complete the necessary configuration before retrying. |
| `INVALID_CAPTCHA` | Security token (Captcha) validation failed. | Ensure the Captcha token is correctly generated on the client side and transmitted without alteration in your request. |
| `UNAUTHORIZED` | Missing or invalid authentication (401). | Verify that your API key is correct, active, and properly formatted in the `Authorization` header (e.g., `Bearer <YOUR_API_KEY>`). |
| `FORBIDDEN` | Access to the resource is denied (403). | Ensure your API key or user account has the necessary permissions and roles to execute this specific action. |
| `UNKNOWN_ERROR` | An unexpected internal server error occurred (500). | Retry the request later. If the issue persists, contact technical support with your request payload and the timestamp of the incident. |
| `RETRY_EXCEPTION` | The operation failed temporarily but can be retried. | Implement an automatic retry logic in your integration, ideally using an exponential backoff strategy. |
| `SCHEMA_FAILED` | Strict data schema validation failed. | Verify that the data types sent (string, number, boolean) exactly match the OpenAPI specification. |
| `TOO_MANY_REQUESTS` | Rate limit exceeded (429). | Reduce the frequency of your API calls. Implement rate-limiting handling based on the response headers. |
| `TWO_FACTOR_AUTH_FAILED` | Two-factor authentication (2FA) failed. | Verify the provided 2FA code. Ensure it was entered correctly and has not expired. |
| `WEBHOOK_INVALID_REQUEST` | The webhook-related request is invalid. | Check your receiving endpoint configuration. Ensure the URL is correct and that your server accepts `POST` requests from Documenso. |
## Envelope State Errors
The following errors occur when attempting to perform actions on an envelope that are incompatible with its current state.
| Error Code | Description | Recommended Action |
| :--- | :--- | :--- |
| `ENVELOPE_DRAFT` | The action cannot be performed because the envelope is still in a draft state. | Finalize the envelope configuration and transition it to the `PENDING` (sent) state before attempting this operation. |
| `ENVELOPE_COMPLETED` | The action cannot be performed because the envelope is already completed. | No further modifications (e.g., adding signers, modifying documents) can be made to an envelope once the signing process is finished. |
| `ENVELOPE_REJECTED` | The action cannot be performed because the envelope was rejected by a recipient. | The signing flow is permanently halted. Create a new envelope if you wish to resubmit the document. |
| `ENVELOPE_LEGACY` | The action cannot be performed because the envelope uses an obsolete format. | This envelope was created with a legacy version of the system. Recreate the envelope using the current API version to interact with it. |
## See Also
- [Documents API](/docs/developers/api/documents)
- [Webhooks](/docs/developers/webhooks)
@@ -1,4 +1,14 @@
{
"title": "API Reference",
"pages": ["documents", "recipients", "fields", "templates", "teams", "rate-limits", "versioning", "developer-mode"]
"pages": [
"documents",
"recipients",
"fields",
"templates",
"teams",
"rate-limits",
"versioning",
"developer-mode",
"common-errors"
]
}
@@ -73,14 +73,14 @@ Include the token in the `Authorization` header of your HTTP requests.
### cURL
```bash
curl https://app.documenso.com/api/v2/documents \
curl https://app.documenso.com/api/v2/document \
-H "Authorization: api_xxxxxxxxxxxxxxxx"
```
### JavaScript / TypeScript
```typescript
const response = await fetch('https://app.documenso.com/api/v2/documents', {
const response = await fetch('https://app.documenso.com/api/v2/document', {
method: 'GET',
headers: {
Authorization: 'api_xxxxxxxxxxxxxxxx',
@@ -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
@@ -1,6 +1,6 @@
---
title: Storage Configuration
description: Configure file storage for uploaded documents and signed PDFs using database storage (default) or S3-compatible object storage.
description: Configure file storage for uploaded documents and signed PDFs using database storage (default), S3-compatible object storage, or Azure Blob Storage.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
@@ -10,10 +10,11 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
## Storage Options
| Backend | Best For | Scalability | Configuration |
| ---------- | -------------------------------- | ----------- | ------------- |
| `database` | Small deployments, simplicity | Limited | None required |
| `s3` | Production, large files, backups | High | Required |
| Backend | Best For | Scalability | Configuration |
| ------------ | --------------------------------------- | ----------- | ------------- |
| `database` | Small deployments, simplicity | Limited | None required |
| `s3` | Production, large files, backups | High | Required |
| `azure-blob` | Production on Azure, native Blob access | High | Required |
Select the storage backend with the `NEXT_PUBLIC_UPLOAD_TRANSPORT` environment variable:
@@ -23,6 +24,9 @@ NEXT_PUBLIC_UPLOAD_TRANSPORT=database
# S3-compatible storage
NEXT_PUBLIC_UPLOAD_TRANSPORT=s3
# Azure Blob Storage (native)
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
```
---
@@ -283,6 +287,111 @@ NEXT_PRIVATE_UPLOAD_REGION=us-east-1
---
## Azure Blob Storage
Azure Blob Storage is supported as a native transport (not S3-compatible). Documenso uses the official `@azure/storage-blob` SDK and signs SAS URLs with the Storage Account key for browser uploads and downloads.
### Required Variables
| Variable | Description |
| --------------------------------------- | ------------------------------------------------- |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Set to `azure-blob` |
| `NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME` | Azure Storage Account name |
| `NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY` | Azure Storage Account access key |
| `NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER` | Container name where uploads are stored |
### Optional Variables
| Variable | Description | Default |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT` | Custom Blob endpoint URL. Useful for local development against Azurite (for example `http://127.0.0.1:10000`). | `https://<account>.blob.core.windows.net` |
### Azure Setup
{/* prettier-ignore */}
<Steps>
<Step>
### Create a Storage Account and Container
Create a Storage Account in the Azure Portal or via the Azure CLI, then create a container inside it:
```bash
az storage account create \
--name yourstorageaccount \
--resource-group your-rg \
--location eastus \
--sku Standard_LRS
az storage container create \
--name documenso-documents \
--account-name yourstorageaccount
```
</Step>
<Step>
### Configure CORS on the container
The browser uploads documents directly to Azure Blob using a SAS URL, and downloads them the same way, so the Storage Account needs CORS rules that allow your application origin:
```bash
az storage cors add \
--services b \
--methods GET PUT \
--origins https://your-documenso-domain.com \
--allowed-headers "Content-Type" "x-ms-blob-type" "Authorization" \
--exposed-headers "*" \
--max-age 3600 \
--account-name yourstorageaccount
```
</Step>
<Step>
### Configure Environment Variables
```bash
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME=yourstorageaccount
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY=your-account-key
NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER=documenso-documents
```
</Step>
</Steps>
### Local Development with Azurite
Azurite is the official Azure Storage emulator. It supports the Blob REST API with account-key authentication.
```bash
docker run -d --name azurite \
-p 10000:10000 -p 10001:10001 -p 10002:10002 \
mcr.microsoft.com/azure-storage/azurite
```
Create the container against the well-known development account:
```bash
az storage container create \
--name documenso-documents \
--connection-string "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"
```
Configure environment variables to point at the emulator:
```bash
NEXT_PUBLIC_UPLOAD_TRANSPORT=azure-blob
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME=devstoreaccount1
NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER=documenso-documents
NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT=http://127.0.0.1:10000
```
<Callout type="info">
The Azurite key shown above is the public well-known development key, published by Microsoft for emulator use. Never reuse it in production.
</Callout>
---
## CloudFront CDN (Optional)
Use Amazon CloudFront to serve documents with lower latency and reduced S3 costs. CloudFront integration uses signed URLs for secure access.
@@ -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
+2 -2
View File
@@ -16,7 +16,7 @@
"fumadocs-ui": "16.5.0",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.2.4",
"next": "16.2.6",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
@@ -29,7 +29,7 @@
"@types/node": "^25.1.0",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.6",
"postcss": "^8.5.14",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
}
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "16.2.4"
"next": "16.2.6"
},
"devDependencies": {
"@types/node": "^20",
@@ -0,0 +1,152 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateUserRequestSchema } from '@documenso/trpc/server/admin-router/create-user.types';
import { Button } from '@documenso/ui/primitives/button';
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 type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
export type AdminUserCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZFormSchema = ZCreateUserRequestSchema;
type TFormSchema = z.infer<typeof ZFormSchema>;
export const AdminUserCreateDialog = ({ trigger, ...props }: AdminUserCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const form = useForm<TFormSchema>({
resolver: zodResolver(ZFormSchema),
defaultValues: {
email: '',
name: '',
},
});
const { mutateAsync: createUser } = trpc.admin.user.create.useMutation();
const onFormSubmit = async (data: TFormSchema) => {
try {
const result = await createUser(data);
await navigate(`/admin/users/${result.userId}`);
setOpen(false);
toast({
title: t`Success`,
description: t`User created and welcome email sent`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`An error occurred`,
description: error.message || t`We encountered an error while creating the user. Please try again later.`,
variant: 'destructive',
});
}
};
useEffect(() => {
form.reset();
}, [open, form]);
return (
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
<Trans>Create User</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Create User</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create a new user. A welcome email will be sent with a link to set their password.</Trans>
</DialogDescription>
</DialogHeader>
<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="email"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input {...field} type="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" data-testid="dialog-create-user-button" loading={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -116,11 +116,12 @@ export const EditorFieldDropdownForm = ({
}
const newValues = [...currentValues];
const removedValue = currentValues[index].value;
newValues.splice(index, 1);
form.setValue('values', newValues);
if (form.getValues('defaultValue') === newValues[index].value) {
if (form.getValues('defaultValue') === removedValue) {
form.setValue('defaultValue', undefined);
}
};
+23 -15
View File
@@ -89,7 +89,6 @@ export const SignInForm = ({
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const twoFactorTurnstileRef = useRef<TurnstileInstance>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
@@ -197,13 +196,31 @@ export const SignInForm = ({
};
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
const $turnstile = isTwoFactorAuthenticationDialogOpen ? twoFactorTurnstileRef.current : turnstileRef.current;
try {
let token: string | undefined;
if (turnstileSiteKey) {
token = await $turnstile?.getResponsePromise(3000).catch((_err) => undefined);
if (!token) {
toast({
title: _(msg`Human verification required`),
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
variant: 'destructive',
});
return;
}
}
await authClient.emailPassword.signIn({
email,
password,
totpCode,
backupCode,
captchaToken: captchaToken ?? undefined,
captchaToken: token ?? undefined,
redirectPath,
});
} catch (err) {
@@ -214,10 +231,6 @@ export const SignInForm = ({
if (error.code === 'TWO_FACTOR_MISSING_CREDENTIALS') {
setIsTwoFactorAuthenticationDialogOpen(true);
// Turnstile tokens are single-use. Clear the consumed one so the
// dialog's fresh widget mounts cleanly and the dialog can't be
// submitted with the stale token before a new one is issued.
setCaptchaToken(null);
return;
}
@@ -247,8 +260,7 @@ export const SignInForm = ({
variant: 'destructive',
});
turnstileRef.current?.reset();
setCaptchaToken(null);
$turnstile?.reset();
}
};
@@ -358,11 +370,9 @@ export const SignInForm = ({
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
appearance: 'always',
}}
/>
)}
@@ -499,11 +509,9 @@ export const SignInForm = ({
<Turnstile
ref={twoFactorTurnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
appearance: 'always',
}}
/>
</div>
@@ -518,7 +526,7 @@ export const SignInForm = ({
)}
</Button>
<Button type="submit" loading={isSubmitting} disabled={Boolean(turnstileSiteKey) && !captchaToken}>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</DialogFooter>
+19 -8
View File
@@ -20,7 +20,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import { Turnstile } from '@marsidev/react-turnstile';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { FaIdCardClip } from 'react-icons/fa6';
import { FcGoogle } from 'react-icons/fc';
@@ -86,8 +86,6 @@ export const SignUpForm = ({
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const hasSocialAuthEnabled = isGoogleSignupEnabled || isMicrosoftSignupEnabled || isOidcSignupEnabled;
const form = useForm<TSignUpFormSchema>({
@@ -105,12 +103,28 @@ export const SignUpForm = ({
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
try {
let token: string | undefined;
if (turnstileSiteKey) {
token = await turnstileRef.current?.getResponsePromise(3000).catch((_err) => undefined);
if (!token) {
toast({
title: _(msg`Human verification required`),
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
variant: 'destructive',
});
return;
}
}
await authClient.emailPassword.signUp({
name,
email,
password,
signature,
captchaToken: captchaToken ?? undefined,
captchaToken: token ?? undefined,
});
await navigate(returnTo ? returnTo : '/unverified-account');
@@ -140,7 +154,6 @@ export const SignUpForm = ({
});
turnstileRef.current?.reset();
setCaptchaToken(null);
}
};
@@ -316,11 +329,9 @@ export const SignUpForm = ({
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
appearance: 'always',
}}
/>
)}
@@ -1,6 +1,7 @@
import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { AppError } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { Button } from '@documenso/ui/primitives/button';
@@ -12,6 +13,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TurnstileInstance } from '@marsidev/react-turnstile';
import { Turnstile } from '@marsidev/react-turnstile';
import { useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
@@ -50,6 +54,9 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
const analytics = useAnalytics();
const navigate = useNavigate();
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const form = useForm<TClaimAccountFormSchema>({
values: {
name: defaultName ?? '',
@@ -61,7 +68,28 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
try {
await authClient.emailPassword.signUp({ name, email, password });
let token: string | undefined;
if (turnstileSiteKey) {
token = await turnstileRef.current?.getResponsePromise(3000).catch((_err) => undefined);
if (!token) {
toast({
title: _(msg`Human verification required`),
description: _(msg`Please complete the CAPTCHA challenge before signing in.`),
variant: 'destructive',
});
return;
}
}
await authClient.emailPassword.signUp({
name,
email,
password,
captchaToken: token ?? undefined,
});
await navigate(`/unverified-account`);
@@ -87,6 +115,8 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
description: _(errorMessage),
variant: 'destructive',
});
turnstileRef.current?.reset();
}
};
@@ -141,6 +171,19 @@ export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) =
)}
/>
{turnstileSiteKey && (
<div className="mt-4">
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{
size: 'flexible',
appearance: 'always',
}}
/>
</div>
)}
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
<Trans>Claim account</Trans>
</Button>
@@ -13,6 +13,7 @@ import { match, P } from 'ts-pattern';
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
import { DocumentSigningAuthExternal2FA } from './document-signing-auth-external-2fa';
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
@@ -58,15 +59,8 @@ export const DocumentSigningAuthDialog = ({
return;
}
// Reset selected auth type when dialog closes
if (!value) {
setSelectedAuthType(() => {
if (validAuthTypes.length === 1) {
return validAuthTypes[0];
}
return null;
});
setSelectedAuthType(validAuthTypes.length === 1 ? validAuthTypes[0] : null);
}
onOpenChange(value);
@@ -123,6 +117,7 @@ export const DocumentSigningAuthDialog = ({
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => <Trans>Verification code</Trans>)
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
.exhaustive()}
</div>
@@ -132,6 +127,9 @@ export const DocumentSigningAuthDialog = ({
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
.with(DocumentAuth.PASSKEY, () => <Trans>Use your passkey for authentication</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>Enter your 2FA code</Trans>)
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => (
<Trans>Enter the verification code provided to you</Trans>
))
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
.exhaustive()}
</div>
@@ -169,6 +167,13 @@ export const DocumentSigningAuthDialog = ({
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuthExternal2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()}
</DialogContent>
@@ -0,0 +1,223 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { SIGNING_2FA_VERIFY_REASON_CODES } from '@documenso/lib/constants/document-auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthExternal2FAProps = {
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
const ZExternal2FAFormSchema = z.object({
code: z
.string()
.length(6, { message: 'Code must be exactly 6 digits' })
.regex(/^\d{6}$/, { message: 'Code must contain only digits' }),
});
type TExternal2FAFormSchema = z.infer<typeof ZExternal2FAFormSchema>;
export const DocumentSigningAuthExternal2FA = ({
onReauthFormSubmit,
open,
onOpenChange,
}: DocumentSigningAuthExternal2FAProps) => {
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const [formError, setFormError] = useState<string | null>(null);
const statusQuery = trpc.envelope.signing2fa.getStatus.useQuery(
{ token: recipient.token },
{ enabled: open },
);
const verifyMutation = trpc.envelope.signing2fa.verify.useMutation();
const form = useForm<TExternal2FAFormSchema>({
resolver: zodResolver(ZExternal2FAFormSchema),
defaultValues: {
code: '',
},
});
const onFormSubmit = async ({ code }: TExternal2FAFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
setFormError(null);
await verifyMutation.mutateAsync({
token: recipient.token,
code,
});
await onReauthFormSubmit({
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED) {
setFormError('Too many failed attempts. Please request a new code.');
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED) {
setFormError('The code has expired. Please request a new code.');
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED) {
setFormError('No code has been issued yet. Please contact the document sender.');
} else {
setFormError('Invalid code. Please try again.');
}
await statusQuery.refetch();
form.reset({ code: '' });
} finally {
setIsCurrentlyAuthenticating(false);
}
};
useEffect(() => {
form.reset({ code: '' });
setFormError(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const attemptsRemaining = statusQuery.data?.attemptsRemaining ?? null;
const hasActiveToken = statusQuery.data?.hasActiveToken ?? false;
const hasValidProof = statusQuery.data?.hasValidProof ?? false;
if (hasValidProof) {
return (
<div className="space-y-4">
<Alert>
<AlertDescription>
<Trans>Your identity has already been verified. You can proceed to sign.</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button
type="button"
onClick={async () => {
await onReauthFormSubmit({
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
});
onOpenChange(false);
}}
>
<Trans>Continue</Trans>
</Button>
</DialogFooter>
</div>
);
}
if (!hasActiveToken && !statusQuery.isLoading) {
return (
<div className="space-y-4">
<Alert variant="warning">
<AlertTitle>
<Trans>Verification code required</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
A verification code is required to sign this document. Please contact the document
sender to request your code.
</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>Enter the 6-digit verification code that was provided to you.</Trans>
</p>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Verification code</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{attemptsRemaining !== null && attemptsRemaining > 0 && (
<p className="text-xs text-muted-foreground">
<Trans>{attemptsRemaining} attempts remaining</Trans>
</p>
)}
{formError && (
<Alert variant="destructive">
<AlertTitle>
<Trans>Verification failed</Trans>
</AlertTitle>
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>Verify</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
);
};
@@ -61,13 +61,13 @@ export const useRequiredDocumentSigningAuthContext = () => {
return context;
};
export interface DocumentSigningAuthProviderProps {
export type DocumentSigningAuthProviderProps = {
documentAuthOptions: Envelope['authOptions'];
recipient: SigningAuthRecipient;
isDirectTemplate?: boolean;
user?: SessionUser | null;
children: React.ReactNode;
}
};
export const DocumentSigningAuthProvider = ({
documentAuthOptions: initialDocumentAuthOptions,
@@ -169,12 +169,12 @@ export const DocumentSigningAuthProvider = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [passkeyData.passkeys]);
// Assume that a user must be logged in for any auth requirements.
const authMethodsRequiringLogin = derivedRecipientActionAuth?.filter(
(method) => method !== DocumentAuth.EXPLICIT_NONE && method !== DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
);
const isAuthRedirectRequired = Boolean(
derivedRecipientActionAuth &&
derivedRecipientActionAuth.length > 0 &&
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
user?.email !== recipient.email,
authMethodsRequiringLogin && authMethodsRequiringLogin.length > 0 && user?.email !== recipient.email,
);
const refetchPasskeys = async () => {
@@ -106,8 +106,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
}))
.with(undefined, () => undefined)
.with(
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
P.union(
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
),
() => 'NOT_SUPPORTED' as const,
)
.exhaustive();
@@ -97,7 +97,7 @@ export const DocumentSigningMobileWidget = () => {
layoutId="document-signing-mobile-widget-progress-bar"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
width: `${requiredRecipientFields.length === 0 ? 100 : 100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
@@ -142,7 +142,7 @@ export const DocumentSigningPageViewV1 = ({
return undefined;
}
const sortedRecipients = allRecipients.sort((a, b) => {
const sortedRecipients = [...allRecipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
@@ -150,7 +150,7 @@ export const DocumentSigningPageViewV2 = () => {
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
width: `${requiredRecipientFields.length === 0 ? 100 : 100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
/>
</div>
@@ -294,7 +294,7 @@ export const EnvelopeSigningProvider = ({
return null;
}
const sortedRecipients = envelope.recipients.sort((a, b) => {
const sortedRecipients = [...envelope.recipients].sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) {
return a.id - b.id;
@@ -334,8 +334,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload);
}
loadingSpinnerGroup.destroy();
})
.finally(() => {
loadingSpinnerGroup.destroy();
@@ -25,7 +25,7 @@ export const CardMetric = ({ icon: Icon, title, value, className, children }: Ca
</div>
)}
<h3 className="mb-2 flex items-end font-medium text-primary-forground text-sm leading-tight">{title}</h3>
<h3 className="mb-2 flex items-end font-medium text-sm leading-tight">{title}</h3>
</div>
{children || (
@@ -238,7 +238,7 @@ export const AdminOrganisationsTable = ({
}}
>
{(table) =>
!hidePaginationUntilOverflow || 1 > table.getPageCount() ? (
!hidePaginationUntilOverflow || table.getPageCount() > 1 ? (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
) : null
}
@@ -1,6 +1,7 @@
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
import { Trans } from '@lingui/react/macro';
import { AdminUserCreateDialog } from '~/components/dialogs/admin-user-create-dialog';
import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table';
import type { Route } from './+types/users._index';
@@ -27,9 +28,13 @@ export default function AdminManageUsersPage({ loaderData }: Route.ComponentProp
return (
<div>
<h2 className="font-semibold text-4xl">
<Trans>Manage users</Trans>
</h2>
<div className="mb-6 flex items-center justify-between">
<h2 className="font-semibold text-4xl">
<Trans>Manage users</Trans>
</h2>
<AdminUserCreateDialog />
</div>
<AdminDashboardUsersTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
</div>
@@ -150,6 +150,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
.with('EXTERNAL_TWO_FACTOR_AUTH', () => _(msg`External Two-Factor Re-Authentication`))
.with('PASSWORD', () => _(msg`Password Re-Authentication`))
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => _(msg`Email`))
@@ -40,6 +40,6 @@ export const handleInitialsFieldClick = async (
return {
type: FieldType.INITIALS,
value: initials,
value: initialsToInsert,
};
};
+5 -258
View File
@@ -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
+1567 -1291
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -88,7 +88,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*",
"@libpdf/core": "^0.3.3",
"@libpdf/core": "^0.3.6",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
@@ -0,0 +1,387 @@
import { prisma } from '@documenso/prisma';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { apiSignin } from '../../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
/**
* Fill in the create-user dialog and submit it.
* Assumes the dialog trigger is already visible on the page.
*/
const submitCreateUserDialog = async ({
page,
email,
name,
}: {
page: import('@playwright/test').Page;
email: string;
name: string;
}) => {
await page.getByRole('button', { name: 'Create User' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByLabel('Email').fill(email);
await dialog.getByLabel('Name').fill(name);
await dialog.getByTestId('dialog-create-user-button').click();
};
// ─── Happy path ──────────────────────────────────────────────────────────────
test('[ADMIN][CREATE_USER]: admin can create a new user via the dialog', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const newUserEmail = seedTestEmail();
const newUserName = 'New Created User';
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await expect(page.getByRole('heading', { name: 'Manage users' })).toBeVisible();
await submitCreateUserDialog({ page, email: newUserEmail, name: newUserName });
// After success the dialog closes and we navigate to /admin/users/:id.
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
// The user-detail page renders the user's name in the heading.
await expect(page.getByRole('heading', { name: `Manage ${newUserName}'s profile` })).toBeVisible();
// The user exists in the database.
const created = await prisma.user.findUnique({
where: { email: newUserEmail.toLowerCase() },
});
expect(created).not.toBeNull();
expect(created?.name).toBe(newUserName);
});
// ─── emailVerified is set + password is null for admin-created users ────────
test('[ADMIN][CREATE_USER]: a newly created user has emailVerified set and no password', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const newUserEmail = seedTestEmail();
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await submitCreateUserDialog({
page,
email: newUserEmail,
name: 'Pending Password User',
});
// Wait for redirect to confirm the request finished.
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
// Admin-created users start with:
// - emailVerified set (the admin vouches for the email)
// - password null (user must set it via the welcome email reset link)
// The "password=null" state hard-blocks login at email-password.ts:101,
// forcing the user through the reset-link flow before they can sign in.
const created = await prisma.user.findUnique({
where: { email: newUserEmail.toLowerCase() },
select: { id: true, emailVerified: true, password: true },
});
expect(created, 'user should exist in the database').not.toBeNull();
expect(
created?.emailVerified,
'admin-created user should have emailVerified set — admin vouches for the email',
).not.toBeNull();
expect(
created?.password,
'admin-created user must have password=null — they must set one via the welcome reset link',
).toBeNull();
});
// ─── Welcome email side effect: a PasswordResetToken is issued ───────────────
test('[ADMIN][CREATE_USER]: creating a user issues a PasswordResetToken valid for ~24 hours', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
const newUserEmail = seedTestEmail();
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
const beforeCreation = Date.now();
await submitCreateUserDialog({
page,
email: newUserEmail,
name: 'Token Recipient',
});
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
const created = await prisma.user.findUniqueOrThrow({
where: { email: newUserEmail.toLowerCase() },
select: { id: true },
});
// The PasswordResetToken is created by an async background job
// (send.admin.user.created.email), so poll until it shows up.
await expect
.poll(
async () => {
const found = await prisma.passwordResetToken.findFirst({
where: { userId: created.id },
});
return found === null ? null : 'found';
},
{
message: `PasswordResetToken for user ${created.id} was not created by the welcome-email job in time`,
timeout: 30_000,
intervals: [250, 500, 1000],
},
)
.toBe('found');
// Now that we know it exists, fetch it with strict types.
const token = await prisma.passwordResetToken.findFirstOrThrow({
where: { userId: created.id },
});
// Token should be ~24h in the future (allow a generous fudge window).
const expiry = token.expiry.getTime();
const expectedExpiry = beforeCreation + 24 * 60 * 60 * 1000;
const driftMs = Math.abs(expiry - expectedExpiry);
// Allow up to 5 minutes of drift (test setup, db round-trips, clock skew,
// plus job scheduling delay).
expect(driftMs, `token expiry should be ~24h from now, drift was ${driftMs}ms`).toBeLessThan(5 * 60 * 1000);
// The token value should be a non-trivial hex string.
expect(token.token.length).toBeGreaterThanOrEqual(32);
expect(token.token).toMatch(/^[a-f0-9]+$/);
});
// ─── Duplicate email is rejected ─────────────────────────────────────────────
test('[ADMIN][CREATE_USER]: creating a user with an email that already exists is rejected', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
// Seed an existing user whose email we'll collide with.
const { user: existingUser } = await seedUser({ isPersonalOrganisation: true });
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await submitCreateUserDialog({
page,
email: existingUser.email,
name: 'Collision Attempt',
});
// The dialog should stay open OR an error toast should surface. Either way
// we must NOT navigate to a new user detail page.
await page.waitForTimeout(1000);
await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/);
// The existing user record must not have been mutated by the attempt.
const stillExisting = await prisma.user.findUnique({
where: { email: existingUser.email },
select: { id: true, name: true, emailVerified: true },
});
expect(stillExisting?.id).toBe(existingUser.id);
expect(stillExisting?.name).toBe(existingUser.name);
// The seeded user was verified — make sure the failed create didn't
// somehow flip the flag.
expect(stillExisting?.emailVerified).not.toBeNull();
// Count of users with this email must still be 1.
const matching = await prisma.user.count({
where: { email: existingUser.email },
});
expect(matching).toBe(1);
});
// ─── Validation: empty form ──────────────────────────────────────────────────
test('[ADMIN][CREATE_USER]: submitting an empty form shows validation errors and does not create a user', async ({
page,
}) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await page.getByRole('button', { name: 'Create User' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Submit without filling anything.
await dialog.getByTestId('dialog-create-user-button').click();
// Validation errors are surfaced for both required fields. Their presence
// proves react-hook-form's zodResolver blocked the submit before the
// mutation ran, so no DB write could have happened.
await expect(dialog.getByLabel('Email')).toHaveAttribute('aria-invalid', 'true');
await expect(dialog.getByLabel('Name')).toHaveAttribute('aria-invalid', 'true');
// Dialog stays open and we must not have navigated to a user detail page.
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/);
});
// ─── Validation: malformed email ─────────────────────────────────────────────
test('[ADMIN][CREATE_USER]: a malformed email is rejected client-side', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await page.getByRole('button', { name: 'Create User' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
const emailInput = dialog.getByLabel('Email');
await emailInput.fill('not-an-email');
await dialog.getByLabel('Name').fill('Some Name');
// The Email input is rendered with type="email" and the form does not set
// noValidate, so the browser's native HTML5 constraint validation rejects
// the malformed value and blocks the submit event from ever firing. (As a
// result react-hook-form's zodResolver never runs and `aria-invalid` is
// not flipped to true — the browser is the layer doing the rejection.) We
// assert directly on the input's ValidityState to prove the value is
// recognised as invalid client-side.
await expect(emailInput).toHaveJSProperty('validity.valid', false);
await dialog.getByTestId('dialog-create-user-button').click();
// Dialog stays open and we must not have navigated.
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/\/admin\/users\/\d+$/);
// The bogus email is definitely not present in the DB — a targeted check
// on a specific row, not a global count, so it's safe to run in parallel.
const bogus = await prisma.user.findFirst({
where: { email: 'not-an-email' },
});
expect(bogus).toBeNull();
});
// ─── Cancel button closes dialog without creating ───────────────────────────
test('[ADMIN][CREATE_USER]: clicking Cancel closes the dialog and does not create a user', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
const newUserEmail = seedTestEmail();
await page.getByRole('button', { name: 'Create User' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
// Fill in valid data but cancel anyway.
await dialog.getByLabel('Email').fill(newUserEmail);
await dialog.getByLabel('Name').fill('Cancelled User');
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).not.toBeVisible();
// No user was created with that email.
const created = await prisma.user.findUnique({
where: { email: newUserEmail.toLowerCase() },
});
expect(created).toBeNull();
});
// ─── Email is lowercased when stored ─────────────────────────────────────────
test('[ADMIN][CREATE_USER]: email entered with mixed case is normalised to lowercase', async ({ page }) => {
const { user: adminUser } = await seedUser({ isAdmin: true });
// Build a known mixed-case email.
const rawEmail = seedTestEmail();
const mixedCaseEmail = rawEmail.replace(/^./, (c) => c.toUpperCase());
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/users',
});
await submitCreateUserDialog({
page,
email: mixedCaseEmail,
name: 'Mixed Case Email User',
});
await expect(page).toHaveURL(/\/admin\/users\/\d+$/, { timeout: 10_000 });
// Look up by lowercased form — that's the canonical storage.
const created = await prisma.user.findUnique({
where: { email: rawEmail.toLowerCase() },
select: { id: true, email: true, emailVerified: true },
});
expect(created).not.toBeNull();
expect(created?.email).toBe(rawEmail.toLowerCase());
// Verified — admin vouches for the email. Case normalisation must not
// affect verification state.
expect(created?.emailVerified).not.toBeNull();
});
// ─── Access control: non-admin cannot see the Create User affordance ────────
test('[ADMIN][CREATE_USER]: non-admin user redirected away from /admin/users and cannot see Create User button', async ({
page,
}) => {
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
await apiSignin({
page,
email: nonAdminUser.email,
redirectPath: '/admin/users',
});
// Non-admins are redirected away from /admin/*; the admin heading must not
// be visible.
await expect(page.getByRole('heading', { name: 'Manage users' })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Create User' })).not.toBeVisible();
});
test('[ADMIN][CREATE_USER]: unauthenticated user cannot access /admin/users', async ({ page }) => {
// No apiSignin — just navigate directly.
await page.goto('/admin/users');
await expect(page).not.toHaveURL(/\/admin\/users$/);
await expect(page.getByRole('button', { name: 'Create User' })).not.toBeVisible();
});
@@ -1,4 +1,5 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
@@ -7,10 +8,11 @@ import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
// Duped from `packages/lib/utils/teams.ts` due to errors when importing that file.
const formatDocumentsPath = (teamUrl: string) => `/t/${teamUrl}/documents`;
@@ -18,6 +20,47 @@ const formatTemplatesPath = (teamUrl: string) => `/t/${teamUrl}/templates`;
const nanoid = customAlphabet('1234567890abcdef', 10);
const expectSigningRequestJobForRecipient = async (recipientId: number) => {
const job = await prisma.backgroundJob.findFirst({
where: {
jobId: 'send.signing.requested.email',
payload: {
path: ['recipientId'],
equals: recipientId,
},
},
});
expect(job).not.toBeNull();
};
const seedSignatureFieldForRecipient = async (options: {
envelopeId: string;
recipientId: number;
positionY: number;
}) => {
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: options.envelopeId },
});
return await prisma.field.create({
data: {
envelopeId: options.envelopeId,
envelopeItemId: envelopeItem.id,
recipientId: options.recipientId,
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: options.positionY,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
},
});
};
test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
@@ -256,11 +299,24 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
},
});
const directTemplateRecipient = template.recipients[0];
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
const secondRecipient = await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
@@ -271,6 +327,12 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
},
});
await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: secondRecipient.id,
positionY: 20,
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
@@ -279,6 +341,12 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
// Sign the direct template recipient's signature field via the UI.
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByText('Next Recipient Name')).toBeVisible();
@@ -309,8 +377,15 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
expect(updatedSecondRecipient).toBeDefined();
if (!updatedSecondRecipient) {
throw new Error('Expected second recipient to exist');
}
expect(updatedSecondRecipient.name).toBe(newName);
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
});
test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with next signer dictation', async ({
@@ -338,11 +413,24 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
},
});
const directTemplateRecipient = template.recipients[0];
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
// Add another signer
await prisma.recipient.create({
const secondRecipient = await prisma.recipient.create({
data: {
signingOrder: 2,
envelopeId: template.id,
@@ -353,10 +441,39 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
},
});
await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: secondRecipient.id,
positionY: 20,
});
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Team direct template link 1' })).toBeVisible();
await page.waitForTimeout(100);
// Wait for the PDF and the Konva canvas overlay to be ready.
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
// Sign the direct template recipient's signature field via the canvas-based V2 UI.
await signSignaturePad(page);
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const x =
(Number(directSignatureField.positionX) / 100) * canvasBox.width +
((Number(directSignatureField.width) / 100) * canvasBox.width) / 2;
const y =
(Number(directSignatureField.positionY) / 100) * canvasBox.height +
((Number(directSignatureField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click();
@@ -394,6 +511,13 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
const updatedSecondRecipient = createdEnvelopeRecipients.find((recipient) => recipient.signingOrder === 2);
expect(updatedSecondRecipient?.name).toBe(newName);
expect(updatedSecondRecipient?.email).toBe(newSecondSignerEmail);
expect(updatedSecondRecipient).toBeDefined();
if (!updatedSecondRecipient) {
throw new Error('Expected second recipient to exist');
}
expect(updatedSecondRecipient.name).toBe(newName);
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
});
@@ -0,0 +1,57 @@
import { Trans } from '@lingui/react/macro';
import { Button, Link, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateAdminUserCreatedProps = {
resetPasswordLink: string;
assetBaseUrl: string;
};
export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: TemplateAdminUserCreatedProps) => {
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
<Trans>Welcome to Documenso!</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>An administrator has created a Documenso account for you.</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>To get started, please set your password by clicking the button below:</Trans>
</Text>
<Section className="mt-8 mb-6 text-center">
<Button
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
href={resetPasswordLink}
>
<Trans>Set Password</Trans>
</Button>
<Text className="mt-8 text-center text-slate-400 text-sm italic">
<Trans>
You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)
</Trans>
</Text>
</Section>
<Section className="mt-8">
<Text className="text-center text-slate-400 text-sm">
<Trans>
If you didn't expect this account or have any questions, please{' '}
<Link href="mailto:support@documenso.com" className="text-documenso-500">
contact support
</Link>
.
</Trans>
</Text>
</Section>
</Section>
</>
);
};
@@ -0,0 +1,45 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';
import type { TemplateAdminUserCreatedProps } from '../template-components/template-admin-user-created';
import { TemplateAdminUserCreated } from '../template-components/template-admin-user-created';
import { TemplateFooter } from '../template-components/template-footer';
export const AdminUserCreatedTemplate = ({
resetPasswordLink,
assetBaseUrl = 'http://localhost:3002',
}: TemplateAdminUserCreatedProps) => {
const { _ } = useLingui();
const previewText = msg`Set your password for Documenso`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
<Section>
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
<TemplateAdminUserCreated resetPasswordLink={resetPasswordLink} assetBaseUrl={assetBaseUrl} />
</Section>
</Container>
<div className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default AdminUserCreatedTemplate;
+1
View File
@@ -14,6 +14,7 @@ export const ZNameSchema = z
.string()
.trim()
.min(3, { message: 'Please enter a valid name.' })
.max(255, { message: 'Name cannot be more than 255 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
});
+13
View File
@@ -26,8 +26,21 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
key: DocumentAuth.PASSWORD,
value: msg`Require password`,
},
[DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH]: {
key: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
value: msg`Require external 2FA`,
},
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: msg`None (Overrides global settings)`,
},
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
export const SIGNING_2FA_VERIFY_REASON_CODES = {
TWO_FA_TOKEN_INVALID: 'TWO_FA_TOKEN_INVALID',
TWO_FA_TOKEN_EXPIRED: 'TWO_FA_TOKEN_EXPIRED',
TWO_FA_TOKEN_REVOKED: 'TWO_FA_TOKEN_REVOKED',
TWO_FA_TOKEN_CONSUMED: 'TWO_FA_TOKEN_CONSUMED',
TWO_FA_ATTEMPT_LIMIT_REACHED: 'TWO_FA_ATTEMPT_LIMIT_REACHED',
TWO_FA_NOT_ISSUED: 'TWO_FA_NOT_ISSUED',
} as const;
+4
View File
@@ -1,6 +1,8 @@
import { JobClient } from './client/client';
import { SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-admin-user-created-email';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
import { SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-completed-emails';
import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-created-from-direct-template-email';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
@@ -29,6 +31,7 @@ import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-e
* triggering jobs.
*/
export const jobsClient = new JobClient([
SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION,
SEND_SIGNING_EMAIL_JOB_DEFINITION,
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
@@ -39,6 +42,7 @@ export const jobsClient = new JobClient([
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION,
SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION,
SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION,
@@ -0,0 +1,67 @@
import { mailer } from '@documenso/email/mailer';
import { AdminUserCreatedTemplate } from '@documenso/email/templates/admin-user-created';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import crypto from 'crypto';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../../constants/email';
import { ONE_DAY } from '../../../constants/time';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendAdminUserCreatedEmailJobDefinition } from './send-admin-user-created-email';
/**
* Send notification email for admin-created users with password reset link.
*
* Creates a password reset token and sends an email explaining:
* - An administrator created their account
* - They need to set their password
* - Support contact if they didn't expect this
*/
export const run = async ({ payload, io }: { payload: TSendAdminUserCreatedEmailJobDefinition; io: JobRunIO }) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: payload.userId,
},
});
const token = await io.runTask(`create-password-reset-token`, async () => {
const passwordResetToken = await prisma.passwordResetToken.create({
data: {
token: crypto.randomBytes(18).toString('hex'),
expiry: new Date(Date.now() + ONE_DAY),
userId: user.id,
},
});
return passwordResetToken.token;
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const resetPasswordLink = `${assetBaseUrl}/reset-password/${token}`;
const emailTemplate = createElement(AdminUserCreatedTemplate, {
assetBaseUrl,
resetPasswordLink,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailTemplate),
renderEmailWithI18N(emailTemplate, { plainText: true }),
]);
const i18n = await getI18nInstance();
return mailer.sendMail({
to: {
address: user.email,
name: user.name || '',
},
from: DOCUMENSO_INTERNAL_EMAIL,
subject: i18n._(msg`Welcome to Documenso`),
html,
text,
});
};
@@ -0,0 +1,31 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID = 'send.admin.user.created.email';
const SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
});
export type TSendAdminUserCreatedEmailJobDefinition = z.infer<
typeof SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION = {
id: SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Admin User Created Email',
version: '1.0.0',
trigger: {
name: SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-admin-user-created-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_ADMIN_USER_CREATED_EMAIL_JOB_DEFINITION_ID,
TSendAdminUserCreatedEmailJobDefinition
>;
@@ -5,29 +5,26 @@ import { msg } from '@lingui/core/macro';
import { DocumentSource, EnvelopeType } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { getFileServerSide } from '../../../universal/upload/get-file.server';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { formatDocumentsPath } from '../../../utils/teams';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendDocumentCompletedEmailsJobDefinition } from './send-document-completed-emails';
export interface SendDocumentOptions {
id: EnvelopeIdOptions;
requestMetadata?: RequestMetadata;
}
export const run = async ({ payload, io }: { payload: TSendDocumentCompletedEmailsJobDefinition; io: JobRunIO }) => {
const { envelopeId, requestMetadata } = payload;
export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOptions) => {
const envelope = await prisma.envelope.findUnique({
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
where: unsafeBuildEnvelopeIdQuery({ type: 'envelopeId', id: envelopeId }, EnvelopeType.DOCUMENT),
include: {
envelopeItems: {
include: {
@@ -0,0 +1,33 @@
import { z } from 'zod';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION_ID = 'send.document.completed.emails';
const SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({
envelopeId: z.string(),
requestMetadata: ZRequestMetadataSchema.optional(),
});
export type TSendDocumentCompletedEmailsJobDefinition = z.infer<
typeof SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION_SCHEMA
>;
export const SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION = {
id: SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION_ID,
name: 'Send Document Completed Emails',
version: '1.0.0',
trigger: {
name: SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION_ID,
schema: SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-document-completed-emails.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_DOCUMENT_COMPLETED_EMAILS_JOB_DEFINITION_ID,
TSendDocumentCompletedEmailsJobDefinition
>;
@@ -14,7 +14,6 @@ import { groupBy } from 'remeda';
import { NEXT_PRIVATE_USE_PLAYWRIGHT_PDF } from '../../../constants/app';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { insertFieldInPDFV1 } from '../../../server-only/pdf/insert-field-in-pdf-v1';
@@ -31,6 +30,7 @@ import { fieldsContainUnsignedRequiredField } from '../../../utils/advanced-fiel
import { isDocumentCompleted } from '../../../utils/document';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { mapDocumentIdToSecondaryId } from '../../../utils/envelope';
import { jobs } from '../../client';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSealDocumentJobDefinition } from './seal-document';
@@ -294,21 +294,6 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
};
});
await io.runTask('send-completed-email', async () => {
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && !isDocumentCompleted(envelopeStatus)) {
shouldSendCompletedEmail = sendEmail;
}
if (shouldSendCompletedEmail) {
await sendCompletedEmail({
id: { type: 'envelopeId', id: envelopeId },
requestMetadata,
});
}
});
const updatedEnvelope = await prisma.envelope.findFirstOrThrow({
where: {
id: envelopeId,
@@ -325,6 +310,22 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
userId: updatedEnvelope.userId,
teamId: updatedEnvelope.teamId ?? undefined,
});
let shouldSendCompletedEmail = sendEmail && !isResealing && !isRejected;
if (isResealing && !isDocumentCompleted(envelopeStatus)) {
shouldSendCompletedEmail = sendEmail;
}
if (shouldSendCompletedEmail) {
await jobs.triggerJob({
name: 'send.document.completed.emails',
payload: {
envelopeId,
requestMetadata,
},
});
}
};
type DecorateAndSignPdfOptions = {
+4 -3
View File
@@ -21,6 +21,7 @@
"@aws-sdk/cloudfront-signer": "^3.998.0",
"@aws-sdk/s3-request-presigner": "^3.998.0",
"@aws-sdk/signature-v4-crt": "^3.998.0",
"@azure/storage-blob": "^12.31.0",
"@bull-board/api": "^6.20.6",
"@bull-board/hono": "^6.20.6",
"@bull-board/ui": "^6.20.6",
@@ -48,7 +49,7 @@
"ioredis": "^5.10.1",
"jose": "^6.1.2",
"konva": "^10.0.9",
"kysely": "0.28.16",
"kysely": "0.29.2",
"luxon": "^3.7.2",
"nanoid": "^5.1.6",
"oslo": "^0.17.0",
@@ -57,8 +58,8 @@
"pino": "^9.14.0",
"pino-pretty": "^13.1.2",
"playwright": "1.56.1",
"postcss": "^8.5.6",
"postcss-selector-parser": "^7.1.0",
"postcss": "^8.5.14",
"postcss-selector-parser": "^7.1.1",
"posthog-js": "^1.297.2",
"posthog-node": "4.18.0",
"react": "^18",
@@ -115,11 +115,28 @@ export const completeDocumentWithToken = async ({
}
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
if (derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH)) {
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId: token,
envelopeId: envelope.id,
expiresAt: { gt: new Date() },
},
});
if (!validProof) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'External 2FA verification required before completing document',
statusCode: 403,
});
}
}
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
if (!accessAuthOptions) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
@@ -31,6 +31,7 @@ type IsRecipientAuthorizedOptions = {
* using the user ID.
*/
authOptions?: TDocumentAuthMethods;
recipientToken?: string;
};
const getUserByEmail = async (email: string) => {
@@ -56,6 +57,7 @@ export const isRecipientAuthorized = async ({
recipient,
userId,
authOptions,
recipientToken,
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: documentAuthOptions,
@@ -163,6 +165,21 @@ export const isRecipientAuthorized = async ({
password,
});
})
.with({ type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, async () => {
if (!recipientToken) {
return false;
}
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId: recipientToken,
envelopeId: recipient.envelopeId,
expiresAt: { gt: new Date() },
},
});
return !!validProof;
})
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
return true;
})
@@ -104,10 +104,6 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
recipientsToNotify = envelope.recipients
.filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC)
.slice(0, 1);
// Secondary filter so we aren't resending if the current active recipient has already
// received the envelope.
recipientsToNotify.filter((r) => r.sendStatus !== SendStatus.SENT);
}
if (envelope.envelopeItems.length === 0) {
@@ -11,6 +11,7 @@ export type ValidateFieldAuthOptions = {
field: Field;
userId?: number;
authOptions?: TRecipientActionAuth;
recipientToken?: string;
};
/**
@@ -24,6 +25,7 @@ export const validateFieldAuth = async ({
field,
userId,
authOptions,
recipientToken,
}: ValidateFieldAuthOptions) => {
// Override all non-signature fields to not require any auth.
if (field.type !== FieldType.SIGNATURE) {
@@ -36,6 +38,7 @@ export const validateFieldAuth = async ({
recipient,
userId,
authOptions,
recipientToken,
});
if (!isValid) {
@@ -177,6 +177,7 @@ export const signFieldWithToken = async ({
field,
userId,
authOptions,
recipientToken: token,
});
const documentMeta = await prisma.documentMeta.findFirst({
@@ -101,6 +101,7 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => i18n._(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => i18n._(msg`Two-Factor Re-Authentication`))
.with('EXTERNAL_TWO_FACTOR_AUTH', () => i18n._(msg`External Two-Factor Re-Authentication`))
.with('PASSWORD', () => i18n._(msg`Password Re-Authentication`))
.with('PASSKEY', () => i18n._(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => i18n._(msg`Email`))
@@ -0,0 +1,105 @@
import { prisma } from '@documenso/prisma';
import { DocumentAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
export type GetSigningTwoFactorStatusOptions = {
recipientId: number;
envelopeId: string;
sessionId: string;
};
export type SigningTwoFactorStatus = {
required: boolean;
hasActiveToken: boolean;
hasValidProof: boolean;
tokenExpiresAt: Date | null;
proofExpiresAt: Date | null;
attemptsRemaining: number | null;
};
const NOT_REQUIRED_STATUS: SigningTwoFactorStatus = {
required: false,
hasActiveToken: false,
hasValidProof: false,
tokenExpiresAt: null,
proofExpiresAt: null,
attemptsRemaining: null,
};
export const getSigningTwoFactorStatus = async ({
recipientId,
envelopeId,
sessionId,
}: GetSigningTwoFactorStatusOptions): Promise<SigningTwoFactorStatus> => {
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
select: {
authOptions: true,
recipients: {
where: { id: recipientId },
select: {
authOptions: true,
},
},
},
});
if (!envelope || envelope.recipients.length === 0) {
return NOT_REQUIRED_STATUS;
}
const [recipient] = envelope.recipients;
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
const required = derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH);
if (!required) {
return NOT_REQUIRED_STATUS;
}
const now = new Date();
const [activeToken, validProof] = await Promise.all([
prisma.signingTwoFactorToken.findFirst({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
expiresAt: { gt: now },
},
orderBy: { createdAt: 'desc' },
select: {
expiresAt: true,
attempts: true,
attemptLimit: true,
},
}),
prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId,
recipientId,
envelopeId,
expiresAt: { gt: now },
},
select: {
expiresAt: true,
},
}),
]);
return {
required: true,
hasActiveToken: !!activeToken,
hasValidProof: !!validProof,
tokenExpiresAt: activeToken?.expiresAt ?? null,
proofExpiresAt: validProof?.expiresAt ?? null,
attemptsRemaining: activeToken
? Math.max(0, activeToken.attemptLimit - activeToken.attempts)
: null,
};
};
@@ -0,0 +1,176 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { DocumentAuth } from '../../types/document-auth';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { generateSigningTwoFactorToken, generateTokenSalt, hashToken } from './token-utils';
const TOKEN_TTL_MINUTES = 10;
const DEFAULT_ATTEMPT_LIMIT = 5;
export const SIGNING_2FA_REASON_CODES = {
TWO_FA_NOT_REQUIRED: 'TWO_FA_NOT_REQUIRED',
TWO_FA_RECIPIENT_INELIGIBLE: 'TWO_FA_RECIPIENT_INELIGIBLE',
TWO_FA_ISSUER_FORBIDDEN: 'TWO_FA_ISSUER_FORBIDDEN',
} as const;
export type IssueSigningTwoFactorTokenOptions = {
recipientId: number;
envelopeId: string;
apiTokenId: number;
};
export const issueSigningTwoFactorToken = async ({
recipientId,
envelopeId,
apiTokenId,
}: IssueSigningTwoFactorTokenOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT,
},
include: {
recipients: {
where: {
id: recipientId,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
statusCode: 404,
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document must be in PENDING status`,
statusCode: 400,
});
}
if (envelope.recipients.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for this document',
statusCode: 404,
});
}
const [recipient] = envelope.recipients;
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
const requiresExternal2FA = derivedRecipientActionAuth.includes(
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
);
if (!requiresExternal2FA) {
await throwIssuanceDenied({
envelopeId,
recipient,
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_NOT_REQUIRED,
});
}
if (recipient.signingStatus === 'SIGNED') {
await throwIssuanceDenied({
envelopeId,
recipient,
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_RECIPIENT_INELIGIBLE,
});
}
const plaintextToken = generateSigningTwoFactorToken();
const salt = generateTokenSalt();
const tokenHash = hashToken(plaintextToken, salt);
const expiresAt = new Date(Date.now() + TOKEN_TTL_MINUTES * 60 * 1000);
const result = await prisma.$transaction(async (tx) => {
await tx.signingTwoFactorToken.updateMany({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
},
data: {
status: 'REVOKED',
revokedAt: new Date(),
},
});
const newToken = await tx.signingTwoFactorToken.create({
data: {
recipientId,
envelopeId,
tokenHash,
tokenSalt: salt,
expiresAt,
attemptLimit: DEFAULT_ATTEMPT_LIMIT,
issuedByApiTokenId: apiTokenId,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: newToken.id,
},
}),
});
return newToken;
});
return {
token: plaintextToken,
tokenId: result.id,
expiresAt: result.expiresAt,
ttlSeconds: TOKEN_TTL_MINUTES * 60,
attemptLimit: result.attemptLimit,
issuedAt: result.createdAt,
};
};
const throwIssuanceDenied = async ({
envelopeId,
recipient,
reasonCode,
}: {
envelopeId: string;
recipient: { id: number; email: string; name: string | null };
reasonCode: string;
}) => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name ?? '',
reasonCode,
},
}),
});
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: reasonCode,
statusCode: 400,
});
};
@@ -0,0 +1,30 @@
import crypto from 'crypto';
const TOKEN_LENGTH = 6;
const SALT_LENGTH = 32;
const HASH_ITERATIONS = 100000;
const HASH_KEY_LENGTH = 64;
const HASH_DIGEST = 'sha512';
export const generateSigningTwoFactorToken = (): string => {
const bytes = crypto.randomBytes(4);
const num = bytes.readUInt32BE(0) % 10 ** TOKEN_LENGTH;
return num.toString().padStart(TOKEN_LENGTH, '0');
};
export const generateTokenSalt = (): string => {
return crypto.randomBytes(SALT_LENGTH).toString('hex');
};
export const hashToken = (token: string, salt: string): string => {
return crypto
.pbkdf2Sync(token, salt, HASH_ITERATIONS, HASH_KEY_LENGTH, HASH_DIGEST)
.toString('hex');
};
export const verifyTokenHash = (token: string, salt: string, expectedHash: string): boolean => {
const hash = hashToken(token, salt);
return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expectedHash, 'hex'));
};
@@ -0,0 +1,245 @@
import { prisma } from '@documenso/prisma';
import { SIGNING_2FA_VERIFY_REASON_CODES } from '../../constants/document-auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { verifyTokenHash } from './token-utils';
export { SIGNING_2FA_VERIFY_REASON_CODES };
const PROOF_TTL_MINUTES = 10;
export type VerifySigningTwoFactorTokenOptions = {
recipientId: number;
envelopeId: string;
token: string;
sessionId: string;
};
export const verifySigningTwoFactorToken = async ({
recipientId,
envelopeId,
token: plaintextToken,
sessionId,
}: VerifySigningTwoFactorTokenOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelopeId,
},
select: {
id: true,
email: true,
name: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
statusCode: 404,
});
}
const activeToken = await prisma.signingTwoFactorToken.findFirst({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
},
orderBy: {
createdAt: 'desc',
},
});
if (!activeToken) {
await throwVerificationError({
envelopeId,
recipient,
tokenId: 'none',
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED,
attemptsUsed: 0,
attemptLimit: 0,
errorCode: AppErrorCode.INVALID_REQUEST,
statusCode: 400,
});
return;
}
if (activeToken.expiresAt < new Date()) {
await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'EXPIRED',
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED,
attemptsUsed: activeToken.attempts,
attemptLimit: activeToken.attemptLimit,
errorCode: AppErrorCode.EXPIRED_CODE,
statusCode: 400,
});
return;
}
if (activeToken.attempts >= activeToken.attemptLimit) {
await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'REVOKED',
revokedAt: new Date(),
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED,
attemptsUsed: activeToken.attempts,
attemptLimit: activeToken.attemptLimit,
errorCode: AppErrorCode.TOO_MANY_REQUESTS,
statusCode: 429,
});
return;
}
const isValid = verifyTokenHash(plaintextToken, activeToken.tokenSalt, activeToken.tokenHash);
if (!isValid) {
const updatedToken = await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
attempts: { increment: 1 },
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_INVALID,
attemptsUsed: updatedToken.attempts,
attemptLimit: updatedToken.attemptLimit,
errorCode: AppErrorCode.INVALID_REQUEST,
statusCode: 400,
});
return;
}
const proofExpiresAt = new Date(Date.now() + PROOF_TTL_MINUTES * 60 * 1000);
const result = await prisma.$transaction(async (tx) => {
await tx.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'CONSUMED',
consumedAt: new Date(),
attempts: { increment: 1 },
},
});
const proof = await tx.signingSessionTwoFactorProof.upsert({
where: {
sessionId_recipientId_envelopeId: {
sessionId,
recipientId,
envelopeId,
},
},
create: {
sessionId,
recipientId,
envelopeId,
expiresAt: proofExpiresAt,
},
update: {
verifiedAt: new Date(),
expiresAt: proofExpiresAt,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: activeToken.id,
},
}),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: activeToken.id,
},
}),
});
return proof;
});
return {
verified: true,
proofId: result.id,
expiresAt: result.expiresAt,
};
};
type ThrowVerificationErrorOptions = {
envelopeId: string;
recipient: { id: number; email: string; name: string };
tokenId: string;
reasonCode: string;
attemptsUsed: number;
attemptLimit: number;
errorCode: AppErrorCode;
statusCode: number;
};
const throwVerificationError = async ({
envelopeId,
recipient,
tokenId,
reasonCode,
attemptsUsed,
attemptLimit,
errorCode,
statusCode,
}: ThrowVerificationErrorOptions): Promise<never> => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId,
reasonCode,
attemptsUsed,
attemptLimit,
},
}),
});
throw new AppError(errorCode, {
message: reasonCode,
statusCode,
});
};
@@ -669,8 +669,6 @@ export const createDocumentFromDirectTemplate = async ({
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
@@ -0,0 +1,43 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface CreateAdminUserOptions {
name: string;
email: string;
}
/**
* Create a user for admin-initiated flows.
*
* Unlike normal signup, this function:
* - Leaves the password unset (`null`); the user must set it later via a password reset/onboarding link
* - Marks the email as verified immediately because this route is only called by admins
* - Does NOT create a personal organisation (user will be added to real org)
* - Returns the user immediately without side effects
*/
export const createAdminUser = async ({ name, email }: CreateAdminUserOptions) => {
const userExists = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
});
if (userExists) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: 'User with this email already exists',
});
}
const user = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password: null,
// Verifying the email here instead of the password reset flow to reduce the
// attack surface. This route is only called by admins.
emailVerified: new Date(),
},
});
return user;
};
+33 -22
View File
@@ -26,30 +26,41 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
throw new AppError(AppErrorCode.ALREADY_EXISTS);
}
const user = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashedPassword, // Todo: (RR7) Drop password.
signature,
},
});
// Todo: (RR7) Migrate to use this after RR7.
// await tx.account.create({
// data: {
// userId: user.id,
// type: 'emailPassword', // Todo: (RR7)
// provider: 'DOCUMENSO', // Todo: (RR7) Enums
// providerAccountId: user.id.toString(),
// password: hashedPassword,
// },
// });
return user;
const user = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashedPassword, // Todo: (RR7) Drop password.
signature,
},
});
// Todo: (RR7) Migrate to use this after RR7.
// Note: If we actually ever proceed with this, there are multiple
// locations where we will need to update this.
// const user = await prisma.$transaction(async (tx) => {
// const user = await tx.user.create({
// data: {
// name,
// email: email.toLowerCase(),
// password: hashedPassword, // Todo: (RR7) Drop password.
// signature,
// },
// });
// await tx.account.create({
// data: {
// userId: user.id,
// type: 'emailPassword', // Todo: (RR7)
// provider: 'DOCUMENSO', // Todo: (RR7) Enums
// providerAccountId: user.id.toString(),
// password: hashedPassword,
// },
// });
// return user;
// });
// Not used at the moment, uncomment if required.
await onCreateUserHook(user).catch((err) => {
// Todo: (RR7) Add logging.
+67 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-05-13 06:46\n"
"PO-Revision-Date: 2026-05-22 05:21\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -1605,6 +1605,10 @@ msgstr "Betrag"
msgid "An admin has deleted your document \"{documentName}\"."
msgstr "Ein Administrator hat dein Dokument \"{documentName}\" gelöscht."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "An administrator has created a Documenso account for you."
msgstr "Ein Administrator hat ein Documenso-Konto für Sie erstellt."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
msgstr "Eine elektronische Unterschrift, die Sie auf unserer Plattform bereitstellen, durch Klicken auf ein Dokument und Eingabe Ihres Namens oder einer anderen von uns bereitgestellten elektronischen Unterzeichnungsart, ist rechtlich bindend. Sie hat das gleiche Gewicht und die gleiche Durchsetzbarkeit wie eine handschriftliche Unterschrift auf Papier."
@@ -1624,6 +1628,7 @@ 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/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
#: apps/remix/app/components/forms/avatar-image.tsx
#: apps/remix/app/components/forms/password.tsx
@@ -2394,6 +2399,7 @@ msgstr "Person nicht gefunden?"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
@@ -3086,6 +3092,7 @@ msgid "Copy Value"
msgstr "Wert kopieren"
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
@@ -3111,6 +3118,10 @@ msgstr "Erstellen Sie eine neue E-Mail-Adresse für Ihre Organisation mit der Do
msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
msgstr "Erstellen Sie eine neue Organisation mit dem {planName} Plan. Behalten Sie Ihre aktuelle Organisation auf dem aktuellen Plan"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create a new user. A welcome email will be sent with a link to set their password."
msgstr "Neuen Benutzer erstellen. Eine Willkommens-E-Mail wird mit einem Link zum Festlegen des Passworts gesendet."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
msgid "Create a support ticket"
msgstr "Erstellen Sie ein Support-Ticket"
@@ -3268,6 +3279,11 @@ msgstr "Erstellen Sie das Dokument als ausstehend und bereit zur Unterschrift."
msgid "Create token"
msgstr "Token erstellen"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create User"
msgstr "Benutzer erstellen"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Create webhook"
msgstr "Webhook erstellen"
@@ -4594,6 +4610,7 @@ msgstr "Elektronische Zustellung von Dokumenten"
msgid "Electronic Signature Disclosure"
msgstr "Offenlegung der elektronischen Unterschrift"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -5835,6 +5852,12 @@ msgstr "Horizontal"
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
msgstr "Wie lange Empfänger Zeit haben, dieses Dokument nach dem Senden zu vervollständigen. Verwendet die Team-Standardeinstellung, wenn \"Vererben\" ausgewählt ist."
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Human verification required"
msgstr "Menschliche Überprüfung erforderlich"
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "I agree to link my account with this organization"
msgstr "Ich bin einverstanden, mein Konto mit dieser Organisation zu verknüpfen."
@@ -5899,6 +5922,10 @@ msgstr "Bei einem Problem mit Ihrem Abo kontaktieren Sie uns bitte unter <0>{SUP
msgid "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
msgstr "Wenn Sie die Staging-Umgebung verwenden, stellen Sie sicher, dass Sie die Host-Eigenschaft der Embedding-Komponente auf die Staging-Domain (https://stg-app.documenso.com) gesetzt haben"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "If you didn't expect this account or have any questions, please <0>contact support</0>."
msgstr "Wenn Sie dieses Konto nicht erwartet haben oder Fragen haben, <0>wenden Sie sich bitte an den Support</0>."
#: packages/email/template-components/template-access-auth-2fa.tsx
msgid "If you didn't request this verification code, you can safely ignore this email."
msgstr "Wenn Sie diesen Verifizierungscode nicht angefordert haben, können Sie diese E-Mail ignorieren."
@@ -6845,6 +6872,7 @@ msgstr "Mein Ordner"
msgid "N/A"
msgstr "N/A"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
@@ -7797,6 +7825,12 @@ msgstr "Bitte überprüfe deine E-Mail auf Updates."
msgid "Please choose your new password"
msgstr "Bitte wählen Sie Ihr neues Passwort"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please complete the CAPTCHA challenge before signing in."
msgstr "Bitte schließen Sie die CAPTCHAPrüfung ab, bevor Sie sich anmelden."
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
@@ -9264,6 +9298,10 @@ msgstr "Sitzungen wurden widerrufen"
msgid "Set a password"
msgstr "Ein Passwort festlegen"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "Set Password"
msgstr "Passwort festlegen"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Richten Sie Ihre Dokumenteigenschaften und Empfängerinformationen ein"
@@ -9272,6 +9310,10 @@ msgstr "Richten Sie Ihre Dokumenteigenschaften und Empfängerinformationen ein"
msgid "Set up your template properties and recipient information"
msgstr "Richten Sie Ihre Vorlageneigenschaften und Empfängerinformationen ein"
#: packages/email/templates/admin-user-created.tsx
msgid "Set your password for Documenso"
msgstr "Lege dein Passwort für Documenso fest"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -9543,8 +9585,8 @@ msgstr "Unterzeichnungszertifikat"
msgid "Signing certificate provided by"
msgstr "Unterzeichnungszertifikat bereitgestellt von"
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
msgid "Signing Complete!"
msgstr "Unterzeichnung abgeschlossen!"
@@ -9892,6 +9934,7 @@ msgstr "Abonnementstatus"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
@@ -11228,6 +11271,10 @@ msgstr "Um die Zwei-Faktor-Authentifizierung zu aktivieren, scannen Sie den folg
msgid "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
msgstr "Um Zugang zu Ihrem Konto zu erhalten, bestätigen Sie bitte Ihre E-Mail-Adresse, indem Sie auf den Bestätigungslink in Ihrem Posteingang klicken."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "To get started, please set your password by clicking the button below:"
msgstr "Um zu beginnen, lege bitte dein Passwort fest, indem du auf den Button unten klickst:"
#. placeholder {0}: recipient.email
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
msgid "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
@@ -11902,6 +11949,10 @@ msgstr "Benutzer"
msgid "User Agent"
msgstr "Benutzer-Agent"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "User created and welcome email sent"
msgstr "Benutzer erstellt und Willkommens-E-Mail gesendet"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "Benutzer hat kein Passwort."
@@ -12296,6 +12347,10 @@ msgstr "Beim Versuch, Ihre Änderungen zu speichern, ist ein Fehler aufgetreten.
msgid "We encountered an error while creating the email. Please try again later."
msgstr "Wir haben beim Erstellen der E-Mail einen Fehler festgestellt. Bitte versuchen Sie es später noch einmal."
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "We encountered an error while creating the user. Please try again later."
msgstr "Beim Erstellen des Benutzers ist ein Fehler aufgetreten. Bitte versuche es später erneut."
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "We encountered an error while removing the direct template link. Please try again later."
msgstr "Wir sind auf einen Fehler gestoßen, während wir den direkten Vorlagenlink entfernt haben. Bitte versuchen Sie es später erneut."
@@ -12665,6 +12720,11 @@ msgstr "Willkommen zurück! Hier ist ein Überblick über Ihr Konto."
msgid "Welcome to {organisationName}"
msgstr "Willkommen bei {organisationName}"
#: packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts
msgid "Welcome to Documenso"
msgstr "Willkommen bei Documenso"
#: packages/email/template-components/template-admin-user-created.tsx
#: packages/email/template-components/template-confirmation-email.tsx
msgid "Welcome to Documenso!"
msgstr "Willkommen bei Documenso!"
@@ -12955,6 +13015,10 @@ msgstr "Sie können im Editor manuell Empfänger hinzufügen."
msgid "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
msgstr "Du kannst diesen Link auch kopieren und in deinen Browser einfügen: {confirmationLink} (Link läuft in 1 Stunde ab)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
msgstr "Du kannst diesen Link auch kopieren und in deinen Browser einfügen: {resetPasswordLink} (Link läuft in 24 Stunden ab)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "You can choose to enable or disable the profile for public view."
msgstr "Sie können wählen, ob das Profil für die öffentliche Ansicht aktiviert oder deaktiviert werden soll."
+66 -2
View File
@@ -1600,6 +1600,10 @@ msgstr "Amount"
msgid "An admin has deleted your document \"{documentName}\"."
msgstr "An admin has deleted your document \"{documentName}\"."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "An administrator has created a Documenso account for you."
msgstr "An administrator has created a Documenso account for you."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
msgstr "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
@@ -1619,6 +1623,7 @@ 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/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
#: apps/remix/app/components/forms/avatar-image.tsx
#: apps/remix/app/components/forms/password.tsx
@@ -2389,6 +2394,7 @@ msgstr "Can't find someone?"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
@@ -3081,6 +3087,7 @@ msgid "Copy Value"
msgstr "Copy Value"
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
@@ -3106,6 +3113,10 @@ msgstr "Create a new email address for your organisation using the domain <0>{0}
msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
msgstr "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create a new user. A welcome email will be sent with a link to set their password."
msgstr "Create a new user. A welcome email will be sent with a link to set their password."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
msgid "Create a support ticket"
msgstr "Create a support ticket"
@@ -3263,6 +3274,11 @@ msgstr "Create the document as pending and ready to sign."
msgid "Create token"
msgstr "Create token"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create User"
msgstr "Create User"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Create webhook"
msgstr "Create webhook"
@@ -4589,6 +4605,7 @@ msgstr "Electronic Delivery of Documents"
msgid "Electronic Signature Disclosure"
msgstr "Electronic Signature Disclosure"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -5830,6 +5847,12 @@ msgstr "Horizontal"
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
msgstr "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Human verification required"
msgstr "Human verification required"
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "I agree to link my account with this organization"
msgstr "I agree to link my account with this organization"
@@ -5894,6 +5917,10 @@ msgstr "If there is any issue with your subscription, please contact us at <0>{S
msgid "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
msgstr "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "If you didn't expect this account or have any questions, please <0>contact support</0>."
msgstr "If you didn't expect this account or have any questions, please <0>contact support</0>."
#: packages/email/template-components/template-access-auth-2fa.tsx
msgid "If you didn't request this verification code, you can safely ignore this email."
msgstr "If you didn't request this verification code, you can safely ignore this email."
@@ -6840,6 +6867,7 @@ msgstr "My Folder"
msgid "N/A"
msgstr "N/A"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
@@ -7792,6 +7820,12 @@ msgstr "Please check your email for updates."
msgid "Please choose your new password"
msgstr "Please choose your new password"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please complete the CAPTCHA challenge before signing in."
msgstr "Please complete the CAPTCHA challenge before signing in."
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
@@ -9259,6 +9293,10 @@ msgstr "Sessions have been revoked"
msgid "Set a password"
msgstr "Set a password"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "Set Password"
msgstr "Set Password"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Set up your document properties and recipient information"
@@ -9267,6 +9305,10 @@ msgstr "Set up your document properties and recipient information"
msgid "Set up your template properties and recipient information"
msgstr "Set up your template properties and recipient information"
#: packages/email/templates/admin-user-created.tsx
msgid "Set your password for Documenso"
msgstr "Set your password for Documenso"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -9538,8 +9580,8 @@ msgstr "Signing Certificate"
msgid "Signing certificate provided by"
msgstr "Signing certificate provided by"
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
msgid "Signing Complete!"
msgstr "Signing Complete!"
@@ -9887,6 +9929,7 @@ msgstr "Subscription Status"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
@@ -11223,6 +11266,10 @@ msgstr "To enable two-factor authentication, scan the following QR code using yo
msgid "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
msgstr "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "To get started, please set your password by clicking the button below:"
msgstr "To get started, please set your password by clicking the button below:"
#. placeholder {0}: recipient.email
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
msgid "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
@@ -11897,6 +11944,10 @@ msgstr "User"
msgid "User Agent"
msgstr "User Agent"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "User created and welcome email sent"
msgstr "User created and welcome email sent"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "User has no password."
@@ -12291,6 +12342,10 @@ msgstr "We encountered an error while attempting to save your changes. Your chan
msgid "We encountered an error while creating the email. Please try again later."
msgstr "We encountered an error while creating the email. Please try again later."
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "We encountered an error while creating the user. Please try again later."
msgstr "We encountered an error while creating the user. Please try again later."
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "We encountered an error while removing the direct template link. Please try again later."
msgstr "We encountered an error while removing the direct template link. Please try again later."
@@ -12660,6 +12715,11 @@ msgstr "Welcome back! Here's an overview of your account."
msgid "Welcome to {organisationName}"
msgstr "Welcome to {organisationName}"
#: packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts
msgid "Welcome to Documenso"
msgstr "Welcome to Documenso"
#: packages/email/template-components/template-admin-user-created.tsx
#: packages/email/template-components/template-confirmation-email.tsx
msgid "Welcome to Documenso!"
msgstr "Welcome to Documenso!"
@@ -12950,6 +13010,10 @@ msgstr "You can add recipients manually in the editor."
msgid "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
msgstr "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
msgstr "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "You can choose to enable or disable the profile for public view."
msgstr "You can choose to enable or disable the profile for public view."
+67 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: es\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-05-13 06:46\n"
"PO-Revision-Date: 2026-05-22 05:22\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -1605,6 +1605,10 @@ msgstr "Cantidad"
msgid "An admin has deleted your document \"{documentName}\"."
msgstr "Un administrador ha eliminado tu documento \"{documentName}\"."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "An administrator has created a Documenso account for you."
msgstr "Un administrador ha creado una cuenta de Documenso para ti."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
msgstr "Una firma electrónica proporcionada por usted en nuestra plataforma, lograda mediante el clic en un documento e ingresando su nombre, o cualquier otro método de firma electrónica que proporcionemos, es legalmente vinculante. Tiene el mismo peso y exigibilidad que una firma manual escrita con tinta en papel."
@@ -1624,6 +1628,7 @@ 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/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
#: apps/remix/app/components/forms/avatar-image.tsx
#: apps/remix/app/components/forms/password.tsx
@@ -2394,6 +2399,7 @@ msgstr "¿No puedes encontrar a alguien?"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
@@ -3086,6 +3092,7 @@ msgid "Copy Value"
msgstr "Copiar valor"
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
@@ -3111,6 +3118,10 @@ msgstr "Crea una nueva dirección de correo electrónico para tu organización u
msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
msgstr "Crea una nueva organización con el plan {planName}. Mantén tu organización actual en su plan actual"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create a new user. A welcome email will be sent with a link to set their password."
msgstr "Crea un nuevo usuario. Se enviará un correo de bienvenida con un enlace para configurar su contraseña."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
msgid "Create a support ticket"
msgstr "Crea un ticket de soporte"
@@ -3268,6 +3279,11 @@ msgstr "Crear el documento como pendiente y listo para firmar."
msgid "Create token"
msgstr "Crear token"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create User"
msgstr "Crear usuario"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Create webhook"
msgstr "Crear webhook"
@@ -4594,6 +4610,7 @@ msgstr "Entrega Electrónica de Documentos"
msgid "Electronic Signature Disclosure"
msgstr "Divulgación de Firma Electrónica"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -5835,6 +5852,12 @@ msgstr "Horizontal"
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
msgstr "Tiempo que tienen los destinatarios para completar este documento después de que se envía. Usa el valor predeterminado del equipo cuando está configurado como heredado."
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Human verification required"
msgstr "Se requiere verificación humana"
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "I agree to link my account with this organization"
msgstr "Acepto vincular mi cuenta con esta organización"
@@ -5899,6 +5922,10 @@ msgstr "Si tienes algún problema con tu suscripción, por favor contáctanos en
msgid "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
msgstr "Si estás utilizando el entorno de pruebas (staging), asegúrate de haber establecido la propiedad host del componente de incrustación al dominio de staging (https://stg-app.documenso.com)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "If you didn't expect this account or have any questions, please <0>contact support</0>."
msgstr "Si no esperabas esta cuenta o tienes alguna pregunta, por favor <0>contacta con soporte</0>."
#: packages/email/template-components/template-access-auth-2fa.tsx
msgid "If you didn't request this verification code, you can safely ignore this email."
msgstr "Si no solicitaste este código de verificación, puedes ignorar este correo electrónico con seguridad."
@@ -6845,6 +6872,7 @@ msgstr "Mi Carpeta"
msgid "N/A"
msgstr "N/A"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
@@ -7797,6 +7825,12 @@ msgstr "Por favor, revisa tu correo electrónico para actualizaciones."
msgid "Please choose your new password"
msgstr "Por favor, elige tu nueva contraseña"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please complete the CAPTCHA challenge before signing in."
msgstr "Complete el desafío CAPTCHA antes de iniciar sesión."
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
@@ -9264,6 +9298,10 @@ msgstr "Las sesiones han sido revocadas"
msgid "Set a password"
msgstr "Establecer una contraseña"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "Set Password"
msgstr "Establecer contraseña"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configura las propiedades de tu documento y la información del destinatario"
@@ -9272,6 +9310,10 @@ msgstr "Configura las propiedades de tu documento y la información del destinat
msgid "Set up your template properties and recipient information"
msgstr "Configura las propiedades de tu plantilla y la información del destinatario"
#: packages/email/templates/admin-user-created.tsx
msgid "Set your password for Documenso"
msgstr "Establece tu contraseña para Documenso"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -9543,8 +9585,8 @@ msgstr "Certificado de Firma"
msgid "Signing certificate provided by"
msgstr "Certificado de firma proporcionado por"
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
msgid "Signing Complete!"
msgstr "¡Firma completa!"
@@ -9892,6 +9934,7 @@ msgstr "Estado de la suscripción"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
@@ -11228,6 +11271,10 @@ msgstr "Para habilitar la autenticación de dos factores, escanea el siguiente c
msgid "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
msgstr "Para acceder a tu cuenta, por favor confirma tu dirección de correo electrónico haciendo clic en el enlace de confirmación de tu bandeja de entrada."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "To get started, please set your password by clicking the button below:"
msgstr "Para comenzar, establece tu contraseña haciendo clic en el botón de abajo:"
#. placeholder {0}: recipient.email
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
msgid "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
@@ -11902,6 +11949,10 @@ msgstr "Usuario"
msgid "User Agent"
msgstr "Agente de usuario"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "User created and welcome email sent"
msgstr "Usuario creado y correo de bienvenida enviado"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "El usuario no tiene contraseña."
@@ -12296,6 +12347,10 @@ msgstr "Se encontró un error mientras se intentaba guardar sus cambios. Sus cam
msgid "We encountered an error while creating the email. Please try again later."
msgstr "Encontramos un error mientras creábamos el correo. Por favor, inténtalo de nuevo más tarde."
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "We encountered an error while creating the user. Please try again later."
msgstr "Se produjo un error al crear el usuario. Por favor, inténtalo de nuevo más tarde."
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "We encountered an error while removing the direct template link. Please try again later."
msgstr "Encontramos un error al eliminar el enlace directo de la plantilla. Por favor, inténtalo de nuevo más tarde."
@@ -12665,6 +12720,11 @@ msgstr "¡Bienvenido de nuevo! Aquí tienes un resumen de tu cuenta."
msgid "Welcome to {organisationName}"
msgstr "Bienvenido a {organisationName}"
#: packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts
msgid "Welcome to Documenso"
msgstr "Bienvenido a Documenso"
#: packages/email/template-components/template-admin-user-created.tsx
#: packages/email/template-components/template-confirmation-email.tsx
msgid "Welcome to Documenso!"
msgstr "¡Bienvenido a Documenso!"
@@ -12955,6 +13015,10 @@ msgstr "Puedes agregar destinatarios manualmente en el editor."
msgid "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
msgstr "También puedes copiar y pegar este enlace en tu navegador: {confirmationLink} (el enlace expira en 1 hora)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
msgstr "También puedes copiar y pegar este enlace en tu navegador: {resetPasswordLink} (el enlace expira en 24 horas)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "You can choose to enable or disable the profile for public view."
msgstr "Puedes elegir habilitar o deshabilitar el perfil para vista pública."
+67 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-05-13 06:46\n"
"PO-Revision-Date: 2026-05-22 05:21\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -1605,6 +1605,10 @@ msgstr "Montant"
msgid "An admin has deleted your document \"{documentName}\"."
msgstr "Un administrateur a supprimé votre document \"{documentName}\"."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "An administrator has created a Documenso account for you."
msgstr "Un administrateur a créé un compte Documenso pour vous."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
msgstr "Une signature électronique fournie par vous sur notre plateforme, obtenue en cliquant sur un document et en saisissant votre nom, ou toute autre méthode de signature électronique que nous fournis, est juridiquement contraignante. Elle a le même poids et la même force exécutoire qu'une signature manuelle écrite à l'encre sur papier."
@@ -1624,6 +1628,7 @@ 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/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
#: apps/remix/app/components/forms/avatar-image.tsx
#: apps/remix/app/components/forms/password.tsx
@@ -2394,6 +2399,7 @@ msgstr "Vous ne trouvez pas quelquun ?"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
@@ -3086,6 +3092,7 @@ msgid "Copy Value"
msgstr "Copier la valeur"
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
@@ -3111,6 +3118,10 @@ msgstr "Créez une nouvelle adresse e-mail pour votre organisation en utilisant
msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
msgstr "Créez une nouvelle organisation avec le plan {planName}. Conservez votre organisation actuelle sur son plan en cours"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create a new user. A welcome email will be sent with a link to set their password."
msgstr "Créez un nouvel utilisateur. Un e-mail de bienvenue lui sera envoyé avec un lien pour définir son mot de passe."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
msgid "Create a support ticket"
msgstr "Créer un ticket de support"
@@ -3268,6 +3279,11 @@ msgstr "Créer le document comme en attente et prêt à signer."
msgid "Create token"
msgstr "Créer un token"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create User"
msgstr "Créer un utilisateur"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Create webhook"
msgstr "Créer un webhook"
@@ -4594,6 +4610,7 @@ msgstr "Remise électronique de documents"
msgid "Electronic Signature Disclosure"
msgstr "Divulgation de signature électronique"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -5835,6 +5852,12 @@ msgstr "Horizontal"
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
msgstr "Durée pendant laquelle les destinataires disposent pour compléter ce document après son envoi. Utilise la valeur par défaut de l’équipe lorsque le mode hérité est sélectionné."
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Human verification required"
msgstr "Vérification humaine requise"
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "I agree to link my account with this organization"
msgstr "J'accepte de lier mon compte avec cette organisation"
@@ -5899,6 +5922,10 @@ msgstr "Si vous rencontrez un problème avec votre abonnement, veuillez nous con
msgid "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
msgstr "Si vous utilisez lenvironnement de staging, assurez-vous davoir défini la propriété host du composant dintégration sur le domaine de staging (https://stg-app.documenso.com)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "If you didn't expect this account or have any questions, please <0>contact support</0>."
msgstr "Si vous nattendiez pas ce compte ou si vous avez des questions, veuillez <0>contacter lassistance</0>."
#: packages/email/template-components/template-access-auth-2fa.tsx
msgid "If you didn't request this verification code, you can safely ignore this email."
msgstr "Si vous n'avez pas demandé ce code de vérification, vous pouvez ignorer cet email en toute sécurité."
@@ -6845,6 +6872,7 @@ msgstr "Mon Dossier"
msgid "N/A"
msgstr "N/A"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
@@ -7797,6 +7825,12 @@ msgstr "Veuillez vérifier votre e-mail pour des mises à jour."
msgid "Please choose your new password"
msgstr "Veuillez choisir votre nouveau mot de passe"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please complete the CAPTCHA challenge before signing in."
msgstr "Veuillez compléter le test CAPTCHA avant de vous connecter."
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
@@ -9264,6 +9298,10 @@ msgstr "Les sessions ont été révoquées"
msgid "Set a password"
msgstr "Définir un mot de passe"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "Set Password"
msgstr "Définir le mot de passe"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configurez les propriétés de votre document et les informations du destinataire"
@@ -9272,6 +9310,10 @@ msgstr "Configurez les propriétés de votre document et les informations du des
msgid "Set up your template properties and recipient information"
msgstr "Configurez les propriétés de votre modèle et les informations du destinataire"
#: packages/email/templates/admin-user-created.tsx
msgid "Set your password for Documenso"
msgstr "Définissez votre mot de passe pour Documenso"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -9543,8 +9585,8 @@ msgstr "Certificat de signature"
msgid "Signing certificate provided by"
msgstr "Certificat de signature fourni par"
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
msgid "Signing Complete!"
msgstr "Signature Complète !"
@@ -9892,6 +9934,7 @@ msgstr "Statut de labonnement"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
@@ -11228,6 +11271,10 @@ msgstr "Pour activer l'authentification à deux facteurs, scannez le code QR sui
msgid "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
msgstr "Pour accéder à votre compte, veuillez confirmer votre adresse e-mail en cliquant sur le lien de confirmation dans votre boîte de réception."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "To get started, please set your password by clicking the button below:"
msgstr "Pour commencer, veuillez définir votre mot de passe en cliquant sur le bouton ci-dessous :"
#. placeholder {0}: recipient.email
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
msgid "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
@@ -11902,6 +11949,10 @@ msgstr "Utilisateur"
msgid "User Agent"
msgstr "Agent utilisateur"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "User created and welcome email sent"
msgstr "Utilisateur créé et e-mail de bienvenue envoyé"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "L'utilisateur n'a pas de mot de passe."
@@ -12296,6 +12347,10 @@ msgstr "Nous avons rencontré une erreur lors de la tentative d'enregistrement d
msgid "We encountered an error while creating the email. Please try again later."
msgstr "Nous avons rencontré une erreur lors de la création de l'e-mail. Veuillez réessayer plus tard."
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "We encountered an error while creating the user. Please try again later."
msgstr "Nous avons rencontré une erreur lors de la création de lutilisateur. Veuillez réessayer plus tard."
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "We encountered an error while removing the direct template link. Please try again later."
msgstr "Une erreur s'est produite lors de la suppression du lien direct vers le modèle. Veuillez réessayer plus tard."
@@ -12665,6 +12720,11 @@ msgstr "Bon retour ! Voici un aperçu de votre compte."
msgid "Welcome to {organisationName}"
msgstr "Bienvenue chez {organisationName}"
#: packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts
msgid "Welcome to Documenso"
msgstr "Bienvenue sur Documenso"
#: packages/email/template-components/template-admin-user-created.tsx
#: packages/email/template-components/template-confirmation-email.tsx
msgid "Welcome to Documenso!"
msgstr "Bienvenue sur Documenso !"
@@ -12955,6 +13015,10 @@ msgstr "Vous pouvez ajouter des destinataires manuellement dans l'éditeur."
msgid "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
msgstr "Vous pouvez également copier et coller ce lien dans votre navigateur : {confirmationLink} (le lien expire dans 1 heure)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
msgstr "Vous pouvez également copier et coller ce lien dans votre navigateur : {resetPasswordLink} (le lien expire dans 24 heures)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "You can choose to enable or disable the profile for public view."
msgstr "Vous pouvez choisir d'activer ou de désactiver le profil pour l'affichage public."
+67 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: it\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-05-13 06:46\n"
"PO-Revision-Date: 2026-05-22 05:22\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -1605,6 +1605,10 @@ msgstr "Importo"
msgid "An admin has deleted your document \"{documentName}\"."
msgstr "Un amministratore ha eliminato il tuo documento \"{documentName}\"."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "An administrator has created a Documenso account for you."
msgstr "Un amministratore ha creato un account Documenso per te."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
msgstr "Una firma elettronica fornita da te sulla nostra piattaforma, ottenuta cliccando su un documento e inserendo il tuo nome, o qualsiasi altro metodo di firma elettronica che forniamo, è legalmente vincolante. Ha lo stesso peso e validità giuridica di una firma manuale scritta con inchiostro su carta."
@@ -1624,6 +1628,7 @@ 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/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
#: apps/remix/app/components/forms/avatar-image.tsx
#: apps/remix/app/components/forms/password.tsx
@@ -2394,6 +2399,7 @@ msgstr "Non riesci a trovare qualcuno?"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
@@ -3086,6 +3092,7 @@ msgid "Copy Value"
msgstr "Copia valore"
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
@@ -3111,6 +3118,10 @@ msgstr "Crea un nuovo indirizzo email per la tua organizzazione utilizzando il d
msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
msgstr "Crea una nuova organizzazione con il piano {planName}. Mantieni la tua organizzazione attuale sul suo piano attuale"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create a new user. A welcome email will be sent with a link to set their password."
msgstr "Crea un nuovo utente. Verrà inviata un'email di benvenuto con un link per impostare la password."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
msgid "Create a support ticket"
msgstr "Crea un ticket di supporto"
@@ -3268,6 +3279,11 @@ msgstr "Crea il documento come in attesa e pronto per la firma."
msgid "Create token"
msgstr "Crea token"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create User"
msgstr "Crea utente"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Create webhook"
msgstr "Crea webhook"
@@ -4594,6 +4610,7 @@ msgstr "Consegna elettronica dei documenti"
msgid "Electronic Signature Disclosure"
msgstr "Divulgazione della firma elettronica"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -5835,6 +5852,12 @@ msgstr "Orizzontale"
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
msgstr "Per quanto tempo i destinatari hanno a disposizione per completare questo documento dopo che è stato inviato. Viene utilizzato il valore predefinito del team quando è impostato su eredita."
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Human verification required"
msgstr "Verifica umana richiesta"
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "I agree to link my account with this organization"
msgstr "Accetto di collegare il mio account con questa organizzazione"
@@ -5899,6 +5922,10 @@ msgstr "Se ci sono problemi con il tuo abbonamento, contattaci a <0>{SUPPORT_EMA
msgid "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
msgstr "Se stai utilizzando lambiente di staging, assicurati di aver impostato la proprietà host del componente di embedding sul dominio di staging (https:\\/\\/stg-app.documenso.com)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "If you didn't expect this account or have any questions, please <0>contact support</0>."
msgstr "Se non ti aspettavi questo account o hai domande, <0>contatta l'assistenza</0>."
#: packages/email/template-components/template-access-auth-2fa.tsx
msgid "If you didn't request this verification code, you can safely ignore this email."
msgstr "Se non hai richiesto questo codice di verifica, puoi tranquillamente ignorare questa email."
@@ -6845,6 +6872,7 @@ msgstr "La Mia Cartella"
msgid "N/A"
msgstr "N/A"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
@@ -7797,6 +7825,12 @@ msgstr "Per favore controlla la tua email per aggiornamenti."
msgid "Please choose your new password"
msgstr "Per favore scegli la tua nuova password"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please complete the CAPTCHA challenge before signing in."
msgstr "Completa la verifica CAPTCHA prima di accedere."
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
@@ -9264,6 +9298,10 @@ msgstr "Le sessioni sono state revocate"
msgid "Set a password"
msgstr "Imposta una password"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "Set Password"
msgstr "Imposta password"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configura le proprietà del documento e le informazioni sui destinatari"
@@ -9272,6 +9310,10 @@ msgstr "Configura le proprietà del documento e le informazioni sui destinatari"
msgid "Set up your template properties and recipient information"
msgstr "Configura le proprietà del modello e le informazioni sui destinatari"
#: packages/email/templates/admin-user-created.tsx
msgid "Set your password for Documenso"
msgstr "Imposta la tua password per Documenso"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -9543,8 +9585,8 @@ msgstr "Certificato di Firma"
msgid "Signing certificate provided by"
msgstr "Certificato di firma fornito da"
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
msgid "Signing Complete!"
msgstr "Firma completata!"
@@ -9892,6 +9934,7 @@ msgstr "Stato dellabbonamento"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
@@ -11228,6 +11271,10 @@ msgstr "Per abilitare l'autenticazione a due fattori, scansiona il seguente codi
msgid "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
msgstr "Per accedere al tuo account, conferma il tuo indirizzo email facendo clic sul link di conferma dalla tua casella di posta."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "To get started, please set your password by clicking the button below:"
msgstr "Per iniziare, imposta la tua password facendo clic sul pulsante qui sotto:"
#. placeholder {0}: recipient.email
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
msgid "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
@@ -11902,6 +11949,10 @@ msgstr "Utente"
msgid "User Agent"
msgstr "User Agent"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "User created and welcome email sent"
msgstr "Utente creato ed email di benvenuto inviata"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "L'utente non ha password."
@@ -12296,6 +12347,10 @@ msgstr "Abbiamo riscontrato un errore durante il tentativo di salvare le modific
msgid "We encountered an error while creating the email. Please try again later."
msgstr "Abbiamo riscontrato un errore durante la creazione dell'email. Riprovare più tardi."
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "We encountered an error while creating the user. Please try again later."
msgstr "Si è verificato un errore durante la creazione dell'utente. Riprova più tardi."
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "We encountered an error while removing the direct template link. Please try again later."
msgstr "Abbiamo riscontrato un errore durante la rimozione del link diretto al modello. Per favore riprova più tardi."
@@ -12665,6 +12720,11 @@ msgstr "Bentornato! Ecco una panoramica del tuo account."
msgid "Welcome to {organisationName}"
msgstr "Benvenuto a {organisationName}"
#: packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts
msgid "Welcome to Documenso"
msgstr "Benvenuto in Documenso"
#: packages/email/template-components/template-admin-user-created.tsx
#: packages/email/template-components/template-confirmation-email.tsx
msgid "Welcome to Documenso!"
msgstr "Benvenuto su Documenso!"
@@ -12955,6 +13015,10 @@ msgstr "Puoi aggiungere manualmente i destinatari nell'editor."
msgid "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
msgstr "Puoi anche copiare e incollare questo link nel tuo browser: {confirmationLink} (il link scade tra 1 ora)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
msgstr "Puoi anche copiare e incollare questo link nel tuo browser: {resetPasswordLink} (il link scade tra 24 ore)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "You can choose to enable or disable the profile for public view."
msgstr "Puoi scegliere di abilitare o disabilitare il profilo per la visualizzazione pubblica."
+67 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ja\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-05-13 06:46\n"
"PO-Revision-Date: 2026-05-22 05:22\n"
"Last-Translator: \n"
"Language-Team: Japanese\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -1605,6 +1605,10 @@ msgstr "金額"
msgid "An admin has deleted your document \"{documentName}\"."
msgstr "管理者によって、あなたのドキュメント「{documentName}」が削除されました。"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "An administrator has created a Documenso account for you."
msgstr "管理者があなたのために Documenso アカウントを作成しました。"
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
msgstr "当社のプラットフォーム上でお客様が行う電子署名(文書をクリックして氏名を入力する、その他当社が提供する方法を用いるなど)は、法的拘束力を持ちます。紙にインクで署名した手書き署名と同等の効力と強制力を持ちます。"
@@ -1624,6 +1628,7 @@ 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/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
#: apps/remix/app/components/forms/avatar-image.tsx
#: apps/remix/app/components/forms/password.tsx
@@ -2394,6 +2399,7 @@ msgstr "メンバーが見つかりませんか?"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
@@ -3086,6 +3092,7 @@ msgid "Copy Value"
msgstr "値をコピー"
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
@@ -3111,6 +3118,10 @@ msgstr "ドメイン <0>{0}</0> を使用して、組織用の新しいメール
msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
msgstr "{planName} プランの新しい組織を作成します。現在の組織はそのままのプランを維持します。"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create a new user. A welcome email will be sent with a link to set their password."
msgstr "新しいユーザーを作成します。パスワードを設定するためのリンクが記載された歓迎メールが送信されます。"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
msgid "Create a support ticket"
msgstr "サポートチケットを作成"
@@ -3268,6 +3279,11 @@ msgstr "文書を保留状態で作成し、署名可能にします。"
msgid "Create token"
msgstr "トークンを作成"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create User"
msgstr "ユーザーを作成"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Create webhook"
msgstr "Webhook を作成"
@@ -4594,6 +4610,7 @@ msgstr "文書の電子的な配信"
msgid "Electronic Signature Disclosure"
msgstr "電子署名に関する開示"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -5835,6 +5852,12 @@ msgstr "横"
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
msgstr "この文書が送信されてから、受信者が完了するまでの猶予期間です。「継承」に設定すると、チームのデフォルト値が使用されます。"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Human verification required"
msgstr "本人確認が必要です"
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "I agree to link my account with this organization"
msgstr "この組織と自分のアカウントをリンクすることに同意します"
@@ -5899,6 +5922,10 @@ msgstr "サブスクリプションに問題がある場合は、<0>{SUPPORT_EMA
msgid "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
msgstr "ステージング環境を使用している場合は、埋め込みコンポーネントの host プロパティをステージングドメイン (https://stg-app.documenso.com) に設定していることを確認してください"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "If you didn't expect this account or have any questions, please <0>contact support</0>."
msgstr "このアカウントに覚えがない場合やご不明な点がある場合は、<0>サポートにお問い合わせください</0>。"
#: packages/email/template-components/template-access-auth-2fa.tsx
msgid "If you didn't request this verification code, you can safely ignore this email."
msgstr "この認証コードをリクエストしていない場合は、このメールは無視してかまいません。"
@@ -6845,6 +6872,7 @@ msgstr "マイフォルダ"
msgid "N/A"
msgstr "N/A"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
@@ -7797,6 +7825,12 @@ msgstr "更新についてはメールをご確認ください。"
msgid "Please choose your new password"
msgstr "新しいパスワードを選択してください"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please complete the CAPTCHA challenge before signing in."
msgstr "サインインする前に CAPTCHA 認証を完了してください。"
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
@@ -9264,6 +9298,10 @@ msgstr "セッションは取り消されました"
msgid "Set a password"
msgstr "パスワードを設定"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "Set Password"
msgstr "パスワードを設定する"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "ドキュメントのプロパティと受信者情報を設定します"
@@ -9272,6 +9310,10 @@ msgstr "ドキュメントのプロパティと受信者情報を設定します
msgid "Set up your template properties and recipient information"
msgstr "テンプレートのプロパティと受信者情報を設定します"
#: packages/email/templates/admin-user-created.tsx
msgid "Set your password for Documenso"
msgstr "Documenso 用のパスワードを設定してください"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -9543,8 +9585,8 @@ msgstr "署名証明書"
msgid "Signing certificate provided by"
msgstr "署名証明書の提供元"
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
msgid "Signing Complete!"
msgstr "署名が完了しました"
@@ -9892,6 +9934,7 @@ msgstr "サブスクリプション状況"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
@@ -11228,6 +11271,10 @@ msgstr "二要素認証を有効にするには、認証アプリで次の QR
msgid "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
msgstr "アカウントにアクセスするには、受信トレイの確認リンクをクリックしてメールアドレスを確認してください。"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "To get started, please set your password by clicking the button below:"
msgstr "開始するには、以下のボタンをクリックしてパスワードを設定してください。"
#. placeholder {0}: recipient.email
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
msgid "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
@@ -11902,6 +11949,10 @@ msgstr "ユーザー"
msgid "User Agent"
msgstr "ユーザーエージェント"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "User created and welcome email sent"
msgstr "ユーザーが作成され、ウェルカムメールが送信されました"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "このユーザーにはパスワードが設定されていません。"
@@ -12296,6 +12347,10 @@ msgstr "変更内容の保存中にエラーが発生しました。現在、変
msgid "We encountered an error while creating the email. Please try again later."
msgstr "メールの作成中にエラーが発生しました。後でもう一度お試しください。"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "We encountered an error while creating the user. Please try again later."
msgstr "ユーザーの作成中にエラーが発生しました。後でもう一度お試しください。"
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "We encountered an error while removing the direct template link. Please try again later."
msgstr "ダイレクトテンプレートリンクの削除中にエラーが発生しました。後でもう一度お試しください。"
@@ -12665,6 +12720,11 @@ msgstr "お帰りなさい!アカウントの概要をご確認ください。
msgid "Welcome to {organisationName}"
msgstr "{organisationName} へようこそ"
#: packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts
msgid "Welcome to Documenso"
msgstr "Documenso へようこそ"
#: packages/email/template-components/template-admin-user-created.tsx
#: packages/email/template-components/template-confirmation-email.tsx
msgid "Welcome to Documenso!"
msgstr "Documenso へようこそ!"
@@ -12955,6 +13015,10 @@ msgstr "エディターで受信者を手動で追加できます。"
msgid "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
msgstr "次のリンクをブラウザにコピー&ペーストして使用することもできます: {confirmationLink}(リンクの有効期限は 1 時間です)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
msgstr "このリンクをブラウザにコピー&ペーストして利用することもできます: {resetPasswordLink}(リンクの有効期限は24時間です)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "You can choose to enable or disable the profile for public view."
msgstr "プロフィールを公開するかどうかを選択できます。"
+67 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ko\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-05-13 06:46\n"
"PO-Revision-Date: 2026-05-22 05:22\n"
"Last-Translator: \n"
"Language-Team: Korean\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -1605,6 +1605,10 @@ msgstr "금액"
msgid "An admin has deleted your document \"{documentName}\"."
msgstr "관리자가 귀하의 \"{documentName}\" 문서를 삭제했습니다."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "An administrator has created a Documenso account for you."
msgstr "관리자가 회원님을 위해 Documenso 계정을 생성했습니다."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
msgstr "플랫폼에서 클릭을 통해 문서에 접근해 이름을 입력하거나, 저희가 제공하는 기타 전자 서명 방식으로 제공된 전자 서명은 법적 효력을 갖습니다. 이는 종이 문서에 펜으로 직접 서명하는 것과 동일한 효력과 집행력을 가집니다."
@@ -1624,6 +1628,7 @@ 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/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
#: apps/remix/app/components/forms/avatar-image.tsx
#: apps/remix/app/components/forms/password.tsx
@@ -2394,6 +2399,7 @@ msgstr "누군가를 찾을 수 없나요?"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
@@ -3086,6 +3092,7 @@ msgid "Copy Value"
msgstr "값 복사"
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
@@ -3111,6 +3118,10 @@ msgstr "도메인 <0>{0}</0>을(를) 사용해 조직용 새 이메일 주소를
msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
msgstr "새 조직을 {planName} 요금제로 생성하고, 현재 조직은 기존 요금제로 유지합니다."
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create a new user. A welcome email will be sent with a link to set their password."
msgstr "새 사용자를 생성합니다. 사용자가 비밀번호를 설정할 수 있는 링크가 포함된 환영 이메일이 전송됩니다."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
msgid "Create a support ticket"
msgstr "지원 티켓 생성"
@@ -3268,6 +3279,11 @@ msgstr "문서를 보류 상태이자 서명 준비 상태로 생성합니다."
msgid "Create token"
msgstr "토큰 생성"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create User"
msgstr "사용자 생성"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Create webhook"
msgstr "웹훅 생성"
@@ -4594,6 +4610,7 @@ msgstr "문서의 전자 제공"
msgid "Electronic Signature Disclosure"
msgstr "전자 서명 고지"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -5835,6 +5852,12 @@ msgstr "가로"
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
msgstr "이 문서가 전송된 후 수신자가 이를 완료할 수 있는 기간입니다. 상속으로 설정된 경우 팀 기본값을 사용합니다."
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Human verification required"
msgstr "사람 인증이 필요합니다"
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "I agree to link my account with this organization"
msgstr "이 조직과 계정을 연결하는 데 동의합니다"
@@ -5899,6 +5922,10 @@ msgstr "구독 관련 문제가 있는 경우 <0>{SUPPORT_EMAIL}</0>로 연락
msgid "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
msgstr "스테이징 환경을 사용 중인 경우, 임베딩 컴포넌트의 host prop을 스테이징 도메인(https://stg-app.documenso.com)으로 설정했는지 확인하세요."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "If you didn't expect this account or have any questions, please <0>contact support</0>."
msgstr "이 계정을 예상하지 못했거나 궁금한 점이 있으시면 <0>지원팀에 문의해 주세요</0>."
#: packages/email/template-components/template-access-auth-2fa.tsx
msgid "If you didn't request this verification code, you can safely ignore this email."
msgstr "이 인증 코드를 요청하지 않았다면, 이 이메일은 무시하셔도 안전합니다."
@@ -6845,6 +6872,7 @@ msgstr "내 폴더"
msgid "N/A"
msgstr "해당 없음"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
@@ -7797,6 +7825,12 @@ msgstr "업데이트 내용을 이메일로 확인해 주세요."
msgid "Please choose your new password"
msgstr "새 비밀번호를 선택해 주세요"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please complete the CAPTCHA challenge before signing in."
msgstr "로그인하기 전에 CAPTCHA 인증을 완료해 주세요."
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
@@ -9264,6 +9298,10 @@ msgstr "세션이 해지되었습니다."
msgid "Set a password"
msgstr "비밀번호 설정"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "Set Password"
msgstr "비밀번호 설정하기"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "문서 속성과 수신자 정보를 설정하세요."
@@ -9272,6 +9310,10 @@ msgstr "문서 속성과 수신자 정보를 설정하세요."
msgid "Set up your template properties and recipient information"
msgstr "템플릿 속성과 수신자 정보를 설정하세요."
#: packages/email/templates/admin-user-created.tsx
msgid "Set your password for Documenso"
msgstr "Documenso 계정의 비밀번호를 설정하세요"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -9543,8 +9585,8 @@ msgstr "서명 인증서"
msgid "Signing certificate provided by"
msgstr "서명 인증서 제공자"
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
msgid "Signing Complete!"
msgstr "서명이 완료되었습니다!"
@@ -9892,6 +9934,7 @@ msgstr "구독 상태"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
@@ -11228,6 +11271,10 @@ msgstr "2단계 인증을 활성화하려면, 인증 앱으로 아래 QR 코드
msgid "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
msgstr "계정에 접근하려면 받은편지함의 확인 링크를 클릭해 이메일 주소를 확인해 주세요."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "To get started, please set your password by clicking the button below:"
msgstr "시작하려면 아래 버튼을 클릭하여 비밀번호를 설정해 주세요."
#. placeholder {0}: recipient.email
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
msgid "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
@@ -11902,6 +11949,10 @@ msgstr "사용자"
msgid "User Agent"
msgstr "User Agent"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "User created and welcome email sent"
msgstr "사용자가 생성되었으며 환영 이메일이 전송되었습니다"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "사용자에게 설정된 비밀번호가 없습니다."
@@ -12296,6 +12347,10 @@ msgstr "변경 사항을 저장하는 동안 오류가 발생했습니다. 현
msgid "We encountered an error while creating the email. Please try again later."
msgstr "이메일을 생성하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "We encountered an error while creating the user. Please try again later."
msgstr "사용자를 생성하는 동안 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "We encountered an error while removing the direct template link. Please try again later."
msgstr "직접 템플릿 링크를 제거하는 중 오류가 발생했습니다. 나중에 다시 시도해 주세요."
@@ -12665,6 +12720,11 @@ msgstr "다시 오신 것을 환영합니다! 계정 개요를 확인해 보세
msgid "Welcome to {organisationName}"
msgstr "{organisationName}에 오신 것을 환영합니다"
#: packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts
msgid "Welcome to Documenso"
msgstr "Documenso에 오신 것을 환영합니다"
#: packages/email/template-components/template-admin-user-created.tsx
#: packages/email/template-components/template-confirmation-email.tsx
msgid "Welcome to Documenso!"
msgstr "Documenso에 오신 것을 환영합니다!"
@@ -12955,6 +13015,10 @@ msgstr "편집기에서 수신자를 수동으로 추가할 수 있습니다."
msgid "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
msgstr "아래 링크를 브라우저에 복사해 붙여넣을 수도 있습니다: {confirmationLink} (링크는 1시간 후 만료됩니다)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
msgstr "다음 링크를 브라우저에 복사해 붙여넣어 사용할 수도 있습니다: {resetPasswordLink} (링크는 24시간 후 만료됩니다)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "You can choose to enable or disable the profile for public view."
msgstr "공개 프로필을 공개하거나 비공개로 전환할 수 있습니다."
+67 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: nl\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-05-13 06:46\n"
"PO-Revision-Date: 2026-05-22 05:21\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -1605,6 +1605,10 @@ msgstr "Bedrag"
msgid "An admin has deleted your document \"{documentName}\"."
msgstr "Een beheerder heeft je document \"{documentName}\" verwijderd."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "An administrator has created a Documenso account for you."
msgstr "Een beheerder heeft een Documenso-account voor je aangemaakt."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
msgstr "Een elektronische handtekening die je via ons platform zet, door door te klikken naar een document en je naam in te voeren of via een andere elektronische ondertekeningsmethode die wij aanbieden, is juridisch bindend. Deze heeft dezelfde rechtsgeldigheid en afdwingbaarheid als een met pen op papier geschreven handtekening."
@@ -1624,6 +1628,7 @@ 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/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
#: apps/remix/app/components/forms/avatar-image.tsx
#: apps/remix/app/components/forms/password.tsx
@@ -2394,6 +2399,7 @@ msgstr "Kunt u niemand vinden?"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
@@ -3086,6 +3092,7 @@ msgid "Copy Value"
msgstr "Waarde kopiëren"
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
@@ -3111,6 +3118,10 @@ msgstr "Maak een nieuw e-mailadres voor je organisatie aan met het domein <0>{0}
msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
msgstr "Maak een nieuwe organisatie aan met het {planName}-abonnement. Laat je huidige organisatie op het huidige abonnement staan"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create a new user. A welcome email will be sent with a link to set their password."
msgstr "Maak een nieuwe gebruiker aan. Er wordt een welkomstmail verzonden met een link om hun wachtwoord in te stellen."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
msgid "Create a support ticket"
msgstr "Maak een supportticket aan"
@@ -3268,6 +3279,11 @@ msgstr "Maak het document aan als in behandeling en klaar om te ondertekenen."
msgid "Create token"
msgstr "Token aanmaken"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create User"
msgstr "Gebruiker aanmaken"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Create webhook"
msgstr "Webhook aanmaken"
@@ -4594,6 +4610,7 @@ msgstr "Elektronische levering van documenten"
msgid "Electronic Signature Disclosure"
msgstr "Kennisgeving elektronische handtekening"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -5835,6 +5852,12 @@ msgstr "Horizontaal"
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
msgstr "Hoe lang ontvangers de tijd hebben om dit document te voltooien nadat het is verzonden. Gebruikt de standaardinstelling van het team wanneer overnemen is geselecteerd."
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Human verification required"
msgstr "Menselijke verificatie vereist"
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "I agree to link my account with this organization"
msgstr "Ik ga akkoord met het koppelen van mijn account aan deze organisatie"
@@ -5899,6 +5922,10 @@ msgstr "Als er een probleem is met je abonnement, neem dan contact met ons op vi
msgid "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
msgstr "Als je staging gebruikt, zorg er dan voor dat je de host-prop op de embeddingcomponent hebt ingesteld op het stagingdomein (https://stg-app.documenso.com)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "If you didn't expect this account or have any questions, please <0>contact support</0>."
msgstr "Als je dit account niet had verwacht of vragen hebt, <0>neem dan contact op met support</0>."
#: packages/email/template-components/template-access-auth-2fa.tsx
msgid "If you didn't request this verification code, you can safely ignore this email."
msgstr "Als u deze verificatiecode niet heeft aangevraagd, kunt u deze e-mail veilig negeren."
@@ -6845,6 +6872,7 @@ msgstr "Mijn map"
msgid "N/A"
msgstr "n.v.t."
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
@@ -7797,6 +7825,12 @@ msgstr "Controleer je email voor updates."
msgid "Please choose your new password"
msgstr "Kies je nieuwe wachtwoord"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please complete the CAPTCHA challenge before signing in."
msgstr "Voltooi de CAPTCHA-uitdaging voordat je je aanmeldt."
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
@@ -9264,6 +9298,10 @@ msgstr "Sessies zijn ingetrokken"
msgid "Set a password"
msgstr "Stel een wachtwoord in"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "Set Password"
msgstr "Stel wachtwoord in"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Stel je documenteigenschappen en ontvangerinformatie in"
@@ -9272,6 +9310,10 @@ msgstr "Stel je documenteigenschappen en ontvangerinformatie in"
msgid "Set up your template properties and recipient information"
msgstr "Stel je sjablooneigenschappen en ontvangerinformatie in"
#: packages/email/templates/admin-user-created.tsx
msgid "Set your password for Documenso"
msgstr "Stel uw wachtwoord voor Documenso in"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -9543,8 +9585,8 @@ msgstr "Ondertekeningscertificaat"
msgid "Signing certificate provided by"
msgstr "Ondertekeningscertificaat verstrekt door"
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
msgid "Signing Complete!"
msgstr "Ondertekening voltooid!"
@@ -9892,6 +9934,7 @@ msgstr "Abonnementsstatus"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
@@ -11228,6 +11271,10 @@ msgstr "Om tweefactorauthenticatie in te schakelen, scan je de volgende QR
msgid "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
msgstr "Om toegang te krijgen tot je account, moet je je emailadres bevestigen door op de bevestigingslink in je inbox te klikken."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "To get started, please set your password by clicking the button below:"
msgstr "Om te beginnen, stel uw wachtwoord in door op de onderstaande knop te klikken:"
#. placeholder {0}: recipient.email
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
msgid "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
@@ -11902,6 +11949,10 @@ msgstr "Gebruiker"
msgid "User Agent"
msgstr "User Agent"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "User created and welcome email sent"
msgstr "Gebruiker aangemaakt en welkomst-e-mail verzonden"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "Gebruiker heeft geen wachtwoord."
@@ -12296,6 +12347,10 @@ msgstr "Er is een fout opgetreden bij het opslaan van uw wijzigingen. Uw wijzigi
msgid "We encountered an error while creating the email. Please try again later."
msgstr "Er is een fout opgetreden bij het aanmaken van de e-mail. Probeer het later opnieuw."
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "We encountered an error while creating the user. Please try again later."
msgstr "Er is een fout opgetreden bij het aanmaken van de gebruiker. Probeer het later opnieuw."
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "We encountered an error while removing the direct template link. Please try again later."
msgstr "Er is een fout opgetreden bij het verwijderen van de directe sjabloonlink. Probeer het later opnieuw."
@@ -12665,6 +12720,11 @@ msgstr "Welkom terug! Hier is een overzicht van je account."
msgid "Welcome to {organisationName}"
msgstr "Welkom bij {organisationName}"
#: packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts
msgid "Welcome to Documenso"
msgstr "Welkom bij Documenso"
#: packages/email/template-components/template-admin-user-created.tsx
#: packages/email/template-components/template-confirmation-email.tsx
msgid "Welcome to Documenso!"
msgstr "Welkom bij Documenso!"
@@ -12955,6 +13015,10 @@ msgstr "U kunt ontvangers handmatig toevoegen in de editor."
msgid "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
msgstr "Je kunt deze link ook kopiëren en in je browser plakken: {confirmationLink} (link verloopt over 1 uur)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
msgstr "U kunt deze link ook kopiëren en in uw browser plakken: {resetPasswordLink} (link verloopt over 24 uur)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "You can choose to enable or disable the profile for public view."
msgstr "Je kunt ervoor kiezen het profiel in of uit te schakelen voor openbare weergave."
+67 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-05-13 06:46\n"
"PO-Revision-Date: 2026-05-22 05:22\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"
@@ -1605,6 +1605,10 @@ msgstr "Kwota"
msgid "An admin has deleted your document \"{documentName}\"."
msgstr "Administrator usunął dokument „{documentName}”."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "An administrator has created a Documenso account for you."
msgstr "Administrator utworzył dla Ciebie konto Documenso."
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
msgstr "Podpis elektroniczny złożony na naszej platformie poprzez kliknięcie dokumentu oraz wpisanie swojego imienia i nazwiska lub za pomocą innej metody podpisywania elektronicznego, którą udostępniamy, jest prawnie wiążący. Ma on taką samą moc i wykonalność jak podpis ręczny złożony na papierze."
@@ -1624,6 +1628,7 @@ 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/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
#: apps/remix/app/components/forms/avatar-image.tsx
#: apps/remix/app/components/forms/password.tsx
@@ -2394,6 +2399,7 @@ msgstr "Nie możesz kogoś znaleźć?"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
@@ -3086,6 +3092,7 @@ msgid "Copy Value"
msgstr "Kopiuj wartość"
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
@@ -3111,6 +3118,10 @@ msgstr "Utwórz nowy adres e-mail organizacji w domenie <0>{0}</0>."
msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
msgstr "Utwórz nową organizację z planem {planName}. Zachowaj obecną organizację w dotychczasowym planie"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create a new user. A welcome email will be sent with a link to set their password."
msgstr "Utwórz nowego użytkownika. Zostanie wysłany email powitalny z łączem do ustawienia hasła."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
msgid "Create a support ticket"
msgstr "Utwórz zgłoszenie"
@@ -3268,6 +3279,11 @@ msgstr "Utwórz dokument jako oczekujący i gotowy do podpisania."
msgid "Create token"
msgstr "Utwórz token"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create User"
msgstr "Utwórz użytkownika"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Create webhook"
msgstr "Utwórz webhook"
@@ -4594,6 +4610,7 @@ msgstr "Elektroniczne dostarczanie dokumentów"
msgid "Electronic Signature Disclosure"
msgstr "Informacje o podpisie elektronicznym"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -5835,6 +5852,12 @@ msgstr "Poziomo"
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
msgstr "Czas, w którym odbiorcy muszą zakończyć dokument. Opcja dziedziczenia korzysta z domyślnych ustawień zespołu."
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Human verification required"
msgstr "Wymagana weryfikacja za pomocą CAPTCHA"
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "I agree to link my account with this organization"
msgstr "Zgadzam się połączyć moje konto z organizacją"
@@ -5899,6 +5922,10 @@ msgstr "Jeśli masz problemy z subskrypcją, skontaktuj się z nami pod adresem
msgid "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
msgstr "Jeśli korzystasz ze środowiska „staging”, ustaw właściwość host na domenę https://stg-app.documenso.com"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "If you didn't expect this account or have any questions, please <0>contact support</0>."
msgstr "Jeśli nie spodziewałeś(-aś) się tego konta lub masz jakiekolwiek pytania, <0>skontaktuj się z pomocą techniczną</0>."
#: packages/email/template-components/template-access-auth-2fa.tsx
msgid "If you didn't request this verification code, you can safely ignore this email."
msgstr "Jeśli prośba wysłania kodu weryfikacyjnego nie została utworzona przez Ciebie, zignoruj wiadomość."
@@ -6845,6 +6872,7 @@ msgstr "Mój folder"
msgid "N/A"
msgstr "Nie dotyczy"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
@@ -7797,6 +7825,12 @@ msgstr "Sprawdź pocztę e-mail."
msgid "Please choose your new password"
msgstr "Wybierz nowe hasło"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please complete the CAPTCHA challenge before signing in."
msgstr "Przed zalogowaniem się dokończ wyzwanie CAPTCHA."
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
@@ -9264,6 +9298,10 @@ msgstr "Sesje zostały unieważnione"
msgid "Set a password"
msgstr "Ustaw hasło"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "Set Password"
msgstr "Ustaw hasło"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Skonfiguruj właściwości dokumentu i informacje o odbiorcach"
@@ -9272,6 +9310,10 @@ msgstr "Skonfiguruj właściwości dokumentu i informacje o odbiorcach"
msgid "Set up your template properties and recipient information"
msgstr "Skonfiguruj właściwości szablonu i informacje o odbiorcach"
#: packages/email/templates/admin-user-created.tsx
msgid "Set your password for Documenso"
msgstr "Ustaw swoje hasło do Documenso"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -9543,8 +9585,8 @@ msgstr "Certyfikat podpisu"
msgid "Signing certificate provided by"
msgstr "Certyfikat podpisu został dostarczony przez"
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
msgid "Signing Complete!"
msgstr "Podpisywanie zostało zakończone!"
@@ -9892,6 +9934,7 @@ msgstr "Status subskrypcji"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
@@ -11228,6 +11271,10 @@ msgstr "Aby włączyć weryfikację dwuetapową, zeskanuj kod QR za pomocą apli
msgid "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
msgstr "Aby uzyskać dostęp do konta, potwierdź adres e-mail, klikając na link potwierdzający w wiadomości."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "To get started, please set your password by clicking the button below:"
msgstr "Aby rozpocząć, ustaw swoje hasło, klikając przycisk poniżej:"
#. placeholder {0}: recipient.email
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
msgid "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
@@ -11902,6 +11949,10 @@ msgstr "Użytkownik"
msgid "User Agent"
msgstr "User agent"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "User created and welcome email sent"
msgstr "Użytkownik został utworzony i wysłano wiadomość powitalną"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "Użytkownik nie ma hasła."
@@ -12296,6 +12347,10 @@ msgstr "Wystąpił błąd podczas zapisania zmian. Zmiany nie mogą zostać zapi
msgid "We encountered an error while creating the email. Please try again later."
msgstr "Wystąpił błąd podczas tworzenia wiadomości. Spróbuj ponownie później."
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "We encountered an error while creating the user. Please try again later."
msgstr "Wystąpił błąd podczas tworzenia użytkownika. Spróbuj ponownie później."
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "We encountered an error while removing the direct template link. Please try again later."
msgstr "Wystąpił błąd podczas usuwania bezpośredniego linku do szablonu. Spróbuj ponownie później."
@@ -12665,6 +12720,11 @@ msgstr "Witaj z powrotem! Oto podsumowanie Twojego konta."
msgid "Welcome to {organisationName}"
msgstr "Witaj w organizacji {organisationName}"
#: packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts
msgid "Welcome to Documenso"
msgstr "Witaj w Documenso"
#: packages/email/template-components/template-admin-user-created.tsx
#: packages/email/template-components/template-confirmation-email.tsx
msgid "Welcome to Documenso!"
msgstr "Witaj w Documenso!"
@@ -12955,6 +13015,10 @@ msgstr "Możesz ręcznie dodać odbiorców w edytorze."
msgid "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
msgstr "Możesz także skopiować i wkleić link do przeglądarki: {confirmationLink} (link wygaśnie za 1 godzinę)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
msgstr "Możesz także skopiować i wkleić ten link do przeglądarki: {resetPasswordLink} (link wygaśnie za 24 godziny)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "You can choose to enable or disable the profile for public view."
msgstr "Możesz zarządzać widocznością profilu."
+66 -2
View File
@@ -1600,6 +1600,10 @@ msgstr "Valor"
msgid "An admin has deleted your document \"{documentName}\"."
msgstr "Um administrador excluiu seu documento \"{documentName}\"."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "An administrator has created a Documenso account for you."
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
msgstr "Uma assinatura eletrônica fornecida por você em nossa plataforma, realizada clicando em um documento e inserindo seu nome, ou qualquer outro método de assinatura eletrônica que fornecemos, é legalmente vinculativa. Ela tem o mesmo peso e aplicabilidade que uma assinatura manual feita com tinta no papel."
@@ -1619,6 +1623,7 @@ 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/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
#: apps/remix/app/components/forms/avatar-image.tsx
#: apps/remix/app/components/forms/password.tsx
@@ -2389,6 +2394,7 @@ msgstr ""
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
@@ -3081,6 +3087,7 @@ msgid "Copy Value"
msgstr ""
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
@@ -3106,6 +3113,10 @@ msgstr "Crie um novo endereço de e-mail para sua organização usando o domíni
msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
msgstr "Crie uma nova organização com o plano {planName}. Mantenha sua organização atual em seu plano atual"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create a new user. A welcome email will be sent with a link to set their password."
msgstr ""
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
msgid "Create a support ticket"
msgstr "Criar um ticket de suporte"
@@ -3263,6 +3274,11 @@ msgstr "Criar o documento como pendente e pronto para assinar."
msgid "Create token"
msgstr "Criar token"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create User"
msgstr ""
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Create webhook"
msgstr "Criar webhook"
@@ -4589,6 +4605,7 @@ msgstr "Entrega Eletrônica de Documentos"
msgid "Electronic Signature Disclosure"
msgstr "Divulgação de Assinatura Eletrônica"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -5830,6 +5847,12 @@ msgstr "Horizontal"
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
msgstr ""
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Human verification required"
msgstr ""
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "I agree to link my account with this organization"
msgstr "Concordo em vincular minha conta a esta organização"
@@ -5894,6 +5917,10 @@ msgstr "Se houver algum problema com sua assinatura, entre em contato conosco em
msgid "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
msgstr ""
#: packages/email/template-components/template-admin-user-created.tsx
msgid "If you didn't expect this account or have any questions, please <0>contact support</0>."
msgstr ""
#: packages/email/template-components/template-access-auth-2fa.tsx
msgid "If you didn't request this verification code, you can safely ignore this email."
msgstr "Se você não solicitou este código de verificação, pode ignorar este e-mail com segurança."
@@ -6840,6 +6867,7 @@ msgstr "Minha Pasta"
msgid "N/A"
msgstr "N/A"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
@@ -7792,6 +7820,12 @@ msgstr "Por favor, verifique seu e-mail para atualizações."
msgid "Please choose your new password"
msgstr "Por favor, escolha sua nova senha"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please complete the CAPTCHA challenge before signing in."
msgstr ""
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
@@ -9259,6 +9293,10 @@ msgstr "Sessões foram revogadas"
msgid "Set a password"
msgstr "Definir uma senha"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "Set Password"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configure as propriedades do seu documento e informações do destinatário"
@@ -9267,6 +9305,10 @@ msgstr "Configure as propriedades do seu documento e informações do destinatá
msgid "Set up your template properties and recipient information"
msgstr "Configure as propriedades do seu modelo e informações do destinatário"
#: packages/email/templates/admin-user-created.tsx
msgid "Set your password for Documenso"
msgstr ""
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -9538,8 +9580,8 @@ msgstr "Certificado de Assinatura"
msgid "Signing certificate provided by"
msgstr "Certificado de assinatura fornecido por"
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
msgid "Signing Complete!"
msgstr "Assinatura Concluída!"
@@ -9887,6 +9929,7 @@ msgstr "Status da Assinatura"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
@@ -11223,6 +11266,10 @@ msgstr "Para ativar a autenticação de dois fatores, escaneie o seguinte códig
msgid "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
msgstr "Para obter acesso à sua conta, confirme seu endereço de e-mail clicando no link de confirmação em sua caixa de entrada."
#: packages/email/template-components/template-admin-user-created.tsx
msgid "To get started, please set your password by clicking the button below:"
msgstr ""
#. placeholder {0}: recipient.email
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
msgid "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
@@ -11897,6 +11944,10 @@ msgstr "Usuário"
msgid "User Agent"
msgstr "Agente de Usuário"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "User created and welcome email sent"
msgstr ""
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "O usuário não tem senha."
@@ -12291,6 +12342,10 @@ msgstr "Encontramos um erro ao tentar salvar suas alterações. Suas alteraçõe
msgid "We encountered an error while creating the email. Please try again later."
msgstr "Encontramos um erro ao criar o e-mail. Por favor, tente novamente mais tarde."
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "We encountered an error while creating the user. Please try again later."
msgstr ""
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "We encountered an error while removing the direct template link. Please try again later."
msgstr "Encontramos um erro ao remover o link direto do modelo. Por favor, tente novamente mais tarde."
@@ -12660,6 +12715,11 @@ msgstr "Bem-vindo de volta! Aqui está uma visão geral da sua conta."
msgid "Welcome to {organisationName}"
msgstr "Bem-vindo à {organisationName}"
#: packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts
msgid "Welcome to Documenso"
msgstr ""
#: packages/email/template-components/template-admin-user-created.tsx
#: packages/email/template-components/template-confirmation-email.tsx
msgid "Welcome to Documenso!"
msgstr "Bem-vindo ao Documenso!"
@@ -12950,6 +13010,10 @@ msgstr "Você pode adicionar destinatários manualmente no editor."
msgid "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
msgstr "Você também pode copiar e colar este link no seu navegador: {confirmationLink} (o link expira em 1 hora)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "You can choose to enable or disable the profile for public view."
msgstr "Você pode optar por ativar ou desativar o perfil para visualização pública."
+67 -3
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-05-13 06:46\n"
"PO-Revision-Date: 2026-05-22 05:21\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -1605,6 +1605,10 @@ msgstr "金额"
msgid "An admin has deleted your document \"{documentName}\"."
msgstr "管理员已删除您的文档“{documentName}”。"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "An administrator has created a Documenso account for you."
msgstr "管理员已为你创建了一个 Documenso 账户。"
#: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx
msgid "An electronic signature provided by you on our platform, achieved through clicking through to a document and entering your name, or any other electronic signing method we provide, is legally binding. It carries the same weight and enforceability as a manual signature written with ink on paper."
msgstr "你在我们平台上提供的电子签名——无论是通过点击文档并输入姓名,还是通过我们提供的其他任何电子签署方式——都具有法律效力,其效力和可执行性与纸质手写签名完全相同。"
@@ -1624,6 +1628,7 @@ 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/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx
#: apps/remix/app/components/forms/avatar-image.tsx
#: apps/remix/app/components/forms/password.tsx
@@ -2394,6 +2399,7 @@ msgstr "找不到某个人?"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
@@ -3086,6 +3092,7 @@ msgid "Copy Value"
msgstr "复制值"
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/folder-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
@@ -3111,6 +3118,10 @@ msgstr "使用域名 <0>{0}</0> 为您的组织创建一个新的邮箱地址。
msgid "Create a new organisation with {planName} plan. Keep your current organisation on it's current plan"
msgstr "创建一个新的组织并使用 {planName} 套餐。保留当前组织在其现有套餐上。"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create a new user. A welcome email will be sent with a link to set their password."
msgstr "创建新用户。系统将发送一封欢迎电子邮件,其中包含用于设置其密码的链接。"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
msgid "Create a support ticket"
msgstr "创建支持工单"
@@ -3268,6 +3279,11 @@ msgstr "将文档创建为待处理状态,并准备好供签署。"
msgid "Create token"
msgstr "创建令牌"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "Create User"
msgstr "创建用户"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Create webhook"
msgstr "创建 Webhook"
@@ -4594,6 +4610,7 @@ msgstr "文档的电子交付"
msgid "Electronic Signature Disclosure"
msgstr "电子签名披露"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
@@ -5835,6 +5852,12 @@ msgstr "水平"
msgid "How long recipients have to complete this document after it is sent. Uses the team default when set to inherit."
msgstr "设置此文档在发送后,收件人可完成签署的时间长度。当设置为继承时,将使用团队默认值。"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Human verification required"
msgstr "需要进行人工验证"
#: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx
msgid "I agree to link my account with this organization"
msgstr "我同意将我的账户与此组织关联"
@@ -5899,6 +5922,10 @@ msgstr "如果您的订阅出现任何问题,请通过 <0>{SUPPORT_EMAIL}</0>
msgid "If you are using staging, ensure that you have set the host prop on the embedding component to the staging domain (https://stg-app.documenso.com)"
msgstr "如果您在使用预发布环境,请确保已将嵌入组件的 host 属性设置为预发布域名 (https://stg-app.documenso.com)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "If you didn't expect this account or have any questions, please <0>contact support</0>."
msgstr "如果你并未预期会收到此账户,或者有任何疑问,请<0>联系支持团队</0>。"
#: packages/email/template-components/template-access-auth-2fa.tsx
msgid "If you didn't request this verification code, you can safely ignore this email."
msgstr "如果您没有请求此验证码,您可以放心忽略这封邮件。"
@@ -6845,6 +6872,7 @@ msgstr "我的文件夹"
msgid "N/A"
msgstr "N/A"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
@@ -7797,6 +7825,12 @@ msgstr "请留意邮箱更新。"
msgid "Please choose your new password"
msgstr "请选择你的新密码"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please complete the CAPTCHA challenge before signing in."
msgstr "在登录之前,请先完成 CAPTCHA 验证。"
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
#: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx
@@ -9264,6 +9298,10 @@ msgstr "会话已被撤销"
msgid "Set a password"
msgstr "设置密码"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "Set Password"
msgstr "设置密码"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "设置文档属性和收件人信息"
@@ -9272,6 +9310,10 @@ msgstr "设置文档属性和收件人信息"
msgid "Set up your template properties and recipient information"
msgstr "设置模板属性和收件人信息"
#: packages/email/templates/admin-user-created.tsx
msgid "Set your password for Documenso"
msgstr "为 Documenso 设置您的密码"
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-command-menu.tsx
#: apps/remix/app/components/general/app-nav-mobile.tsx
@@ -9543,8 +9585,8 @@ msgstr "签署证书"
msgid "Signing certificate provided by"
msgstr "签署证书由以下机构提供"
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/server-only/document/send-completed-email.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
#: packages/lib/jobs/definitions/emails/send-document-completed-emails.handler.ts
msgid "Signing Complete!"
msgstr "签署完成!"
@@ -9892,6 +9934,7 @@ msgstr "订阅状态"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
@@ -11228,6 +11271,10 @@ msgstr "要启用双重验证,请使用验证器应用扫描以下二维码。
msgid "To gain access to your account, please confirm your email address by clicking on the confirmation link from your inbox."
msgstr "要访问你的账号,请点击收件箱中的确认链接以验证邮箱地址。"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "To get started, please set your password by clicking the button below:"
msgstr "要开始使用,请点击下方按钮设置您的密码:"
#. placeholder {0}: recipient.email
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
msgid "To mark this document as viewed, you need to be logged in as <0>{0}</0>"
@@ -11902,6 +11949,10 @@ msgstr "用户"
msgid "User Agent"
msgstr "用户代理"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "User created and welcome email sent"
msgstr "用户已创建并已发送欢迎电子邮件"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "用户没有密码。"
@@ -12296,6 +12347,10 @@ msgstr "保存更改时遇到错误。您的更改目前无法保存。"
msgid "We encountered an error while creating the email. Please try again later."
msgstr "创建邮箱时遇到错误。请稍后重试。"
#: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx
msgid "We encountered an error while creating the user. Please try again later."
msgstr "创建用户时遇到错误。请稍后再试。"
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
msgid "We encountered an error while removing the direct template link. Please try again later."
msgstr "移除直接模板链接时出错。请稍后再试。"
@@ -12665,6 +12720,11 @@ msgstr "欢迎回来!以下是您的账户概览。"
msgid "Welcome to {organisationName}"
msgstr "欢迎加入 {organisationName}"
#: packages/lib/jobs/definitions/emails/send-admin-user-created-email.handler.ts
msgid "Welcome to Documenso"
msgstr "欢迎使用 Documenso"
#: packages/email/template-components/template-admin-user-created.tsx
#: packages/email/template-components/template-confirmation-email.tsx
msgid "Welcome to Documenso!"
msgstr "欢迎来到 Documenso"
@@ -12955,6 +13015,10 @@ msgstr "您可以在编辑器中手动添加收件人。"
msgid "You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)"
msgstr "您也可以将此链接复制并粘贴到浏览器中:{confirmationLink}(链接 1 小时后过期)"
#: packages/email/template-components/template-admin-user-created.tsx
msgid "You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)"
msgstr "您也可以将此链接复制并粘贴到浏览器中打开:{resetPasswordLink}(链接将在 24 小时后失效)"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "You can choose to enable or disable the profile for public view."
msgstr "您可以选择启用或禁用公开个人资料。"
+5
View File
@@ -26,4 +26,9 @@ export type BaseApiLog = Partial<RootApiLog> & {
export type TrpcApiLog = BaseApiLog & {
trpcMiddleware: string;
unverifiedTeamId?: number | null;
/**
* Used to differentiate between batched TRPC requests sharing the same
* underlying HTTP `requestId`.
*/
nonBatchedRequestId?: string;
};
+67
View File
@@ -54,6 +54,14 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
// External signing 2FA events.
'EXTERNAL_2FA_TOKEN_ISSUED',
'EXTERNAL_2FA_TOKEN_ISSUE_DENIED',
'EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED',
'EXTERNAL_2FA_TOKEN_VERIFY_FAILED',
'EXTERNAL_2FA_TOKEN_CONSUMED',
'EXTERNAL_2FA_TOKEN_REVOKED',
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@@ -710,6 +718,59 @@ export const ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema = z.objec
}),
});
const ZExternal2FARecipientDataSchema = z.object({
recipientId: z.number(),
recipientEmail: z.string(),
recipientName: z.string(),
});
export const ZDocumentAuditLogEventExternal2FATokenIssuedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string().optional(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED),
data: ZExternal2FARecipientDataSchema.extend({
reasonCode: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string(),
attemptsUsed: z.number(),
attemptLimit: z.number(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenConsumedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenRevokedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string(),
}),
});
/**
* Event: Recipient's signing window expired.
*/
@@ -769,6 +830,12 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventExternal2FATokenIssuedSchema,
ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema,
ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema,
ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema,
ZDocumentAuditLogEventExternal2FATokenConsumedSchema,
ZDocumentAuditLogEventExternal2FATokenRevokedSchema,
ZDocumentAuditLogEventRecipientExpiredSchema,
]),
);
+23 -2
View File
@@ -5,7 +5,14 @@ import { ZAuthenticationResponseJSONSchema } from './webauthn';
/**
* All the available types of document authentication options for both access and action.
*/
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'TWO_FACTOR_AUTH', 'PASSWORD', 'EXPLICIT_NONE']);
export const ZDocumentAuthTypesSchema = z.enum([
'ACCOUNT',
'PASSKEY',
'TWO_FACTOR_AUTH',
'EXTERNAL_TWO_FACTOR_AUTH',
'PASSWORD',
'EXPLICIT_NONE',
]);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
@@ -34,6 +41,10 @@ const ZDocumentAuth2FASchema = z.object({
method: z.enum(['email', 'authenticator']).default('authenticator').optional(),
});
const ZDocumentAuthExternal2FASchema = z.object({
type: z.literal(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH),
});
/**
* All the document auth methods for both accessing and actioning.
*/
@@ -42,6 +53,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthExplicitNoneSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
]);
@@ -67,10 +79,17 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
]);
export const ZDocumentActionAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD])
.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
])
.describe(
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
);
@@ -97,6 +116,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
ZDocumentAuthExplicitNoneSchema,
]);
@@ -105,6 +125,7 @@ export const ZRecipientActionAuthTypesSchema = z
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
DocumentAuth.EXPLICIT_NONE,
])
+7
View File
@@ -34,6 +34,8 @@ export const ZClaimFlagsSchema = z.object({
allowLegacyEnvelopes: z.boolean().optional(),
externalSigning2fa: z.boolean().optional(),
signingReminders: z.boolean().optional(),
});
@@ -102,6 +104,11 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'allowLegacyEnvelopes',
label: 'Allow Legacy Envelopes',
},
externalSigning2fa: {
key: 'externalSigning2fa',
label: 'External signing 2FA',
isEnterprise: true,
},
signingReminders: {
key: 'signingReminders',
label: 'Signing reminders',
@@ -0,0 +1,100 @@
import path from 'node:path';
import {
BlobSASPermissions,
BlobServiceClient,
generateBlobSASQueryParameters,
StorageSharedKeyCredential,
} from '@azure/storage-blob';
import { env } from '@documenso/lib/utils/env';
import slugify from '@sindresorhus/slugify';
import { ONE_HOUR } from '../../../constants/time';
import { alphaid } from '../../id';
import type { PresignedUrl, StorageProvider, UploadFileInput, UploadFileResult } from './storage-provider';
export class AzureBlobProvider implements StorageProvider {
private serviceClient: BlobServiceClient;
private credential: StorageSharedKeyCredential;
private containerName: string;
constructor() {
const accountName = String(env('NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_NAME'));
const accountKey = String(env('NEXT_PRIVATE_UPLOAD_AZURE_ACCOUNT_KEY'));
this.containerName = String(env('NEXT_PRIVATE_UPLOAD_AZURE_CONTAINER'));
this.credential = new StorageSharedKeyCredential(accountName, accountKey);
const endpointOverride = env('NEXT_PRIVATE_UPLOAD_AZURE_ENDPOINT');
const url = endpointOverride
? `${endpointOverride}/${accountName}`
: `https://${accountName}.blob.core.windows.net`;
this.serviceClient = new BlobServiceClient(url, this.credential);
}
private buildSasUrl(key: string, permissions: BlobSASPermissions): string {
const expiresOn = new Date(Date.now() + ONE_HOUR);
const sasToken = generateBlobSASQueryParameters(
{
containerName: this.containerName,
blobName: key,
permissions,
expiresOn,
},
this.credential,
).toString();
const blobClient = this.serviceClient.getContainerClient(this.containerName).getBlobClient(key);
return `${blobClient.url}?${sasToken}`;
}
async getPresignPostUrl(fileName: string, _contentType: string, userId?: number): Promise<PresignedUrl> {
const { name, ext } = path.parse(fileName);
let slugified = slugify(name);
if (slugified.length === 0 || slugified.length > 100) {
slugified = alphaid(8);
}
let key = `${alphaid(12)}/${slugified}${ext}`;
if (userId) {
key = `${userId}/${key}`;
}
const url = this.buildSasUrl(key, BlobSASPermissions.parse('cw'));
return { key, url };
}
async getAbsolutePresignPostUrl(key: string): Promise<PresignedUrl> {
const url = this.buildSasUrl(key, BlobSASPermissions.parse('cw'));
return { key, url };
}
async getPresignGetUrl(key: string): Promise<PresignedUrl> {
const url = this.buildSasUrl(key, BlobSASPermissions.parse('r'));
return { key, url };
}
async uploadFile(input: UploadFileInput): Promise<UploadFileResult> {
const { name, ext } = path.parse(input.name);
const key = `${alphaid(12)}/${slugify(name)}${ext}`;
const containerClient = this.serviceClient.getContainerClient(this.containerName);
const blockBlobClient = containerClient.getBlockBlobClient(key);
const body = input.body instanceof ArrayBuffer ? Buffer.from(input.body) : input.body;
await blockBlobClient.uploadData(body, {
blobHTTPHeaders: { blobContentType: input.type },
});
return { key };
}
async deleteFile(key: string): Promise<void> {
const containerClient = this.serviceClient.getContainerClient(this.containerName);
await containerClient.deleteBlob(key);
}
}
@@ -0,0 +1,28 @@
import { env } from '@documenso/lib/utils/env';
import { AzureBlobProvider } from './azure-blob-provider';
import { S3Provider } from './s3-provider';
import type { StorageProvider } from './storage-provider';
export type { PresignedUrl, StorageProvider, UploadFileInput, UploadFileResult } from './storage-provider';
let cached: StorageProvider | null = null;
export const getStorageProvider = (): StorageProvider => {
if (cached) {
return cached;
}
const transport = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
switch (transport) {
case 's3':
cached = new S3Provider();
return cached;
case 'azure-blob':
cached = new AzureBlobProvider();
return cached;
default:
throw new Error(`Invalid object storage transport: "${transport}". Expected "s3" or "azure-blob".`);
}
};
@@ -0,0 +1,120 @@
import path from 'node:path';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { env } from '@documenso/lib/utils/env';
import slugify from '@sindresorhus/slugify';
import { ONE_HOUR, ONE_SECOND } from '../../../constants/time';
import { alphaid } from '../../id';
import type { PresignedUrl, StorageProvider, UploadFileInput, UploadFileResult } from './storage-provider';
export class S3Provider implements StorageProvider {
private client: S3Client;
constructor() {
const hasCredentials = env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID') && env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY');
this.client = new S3Client({
endpoint: env('NEXT_PRIVATE_UPLOAD_ENDPOINT') || undefined,
forcePathStyle: env('NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE') === 'true',
region: env('NEXT_PRIVATE_UPLOAD_REGION') || 'us-east-1',
credentials: hasCredentials
? {
accessKeyId: String(env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID')),
secretAccessKey: String(env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY')),
}
: undefined,
});
}
async getPresignPostUrl(fileName: string, contentType: string, userId?: number): Promise<PresignedUrl> {
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const { name, ext } = path.parse(fileName);
let slugified = slugify(name);
if (slugified.length === 0 || slugified.length > 100) {
slugified = alphaid(8);
}
let key = `${alphaid(12)}/${slugified}${ext}`;
if (userId) {
key = `${userId}/${key}`;
}
const command = new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(this.client, command, { expiresIn: ONE_HOUR / ONE_SECOND });
return { key, url };
}
async getAbsolutePresignPostUrl(key: string): Promise<PresignedUrl> {
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const command = new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
});
const url = await getSignedUrl(this.client, command, { expiresIn: ONE_HOUR / ONE_SECOND });
return { key, url };
}
async getPresignGetUrl(key: string): Promise<PresignedUrl> {
if (env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN')) {
const distributionUrl = new URL(key, `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN')}`);
const { getSignedUrl: getCloudfrontSignedUrl } = await import('@aws-sdk/cloudfront-signer');
const url = getCloudfrontSignedUrl({
url: distributionUrl.toString(),
keyPairId: `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID')}`,
privateKey: `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_CONTENTS')}`,
dateLessThan: new Date(Date.now() + ONE_HOUR).toISOString(),
});
return { key, url };
}
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const command = new GetObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
});
const url = await getSignedUrl(this.client, command, { expiresIn: ONE_HOUR / ONE_SECOND });
return { key, url };
}
async uploadFile(input: UploadFileInput): Promise<UploadFileResult> {
const { name, ext } = path.parse(input.name);
const key = `${alphaid(12)}/${slugify(name)}${ext}`;
const body = input.body instanceof ArrayBuffer ? Buffer.from(input.body) : input.body;
await this.client.send(
new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
Body: body,
ContentType: input.type,
}),
);
return { key };
}
async deleteFile(key: string): Promise<void> {
await this.client.send(
new DeleteObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
}),
);
}
}
@@ -0,0 +1,44 @@
export type PresignedUrl = {
key: string;
url: string;
};
export type UploadFileInput = {
name: string;
type: string;
body: ArrayBuffer | Buffer;
};
export type UploadFileResult = {
key: string;
};
export interface StorageProvider {
/**
* Generate a presigned URL to upload a file by name. The provider chooses the
* final object key (typically derived from a slugified file name plus a
* random prefix) and returns it along with the signed URL.
*/
getPresignPostUrl(fileName: string, contentType: string, userId?: number): Promise<PresignedUrl>;
/**
* Generate a presigned URL to upload to an already-known key (used for flows
* where the destination has been chosen previously).
*/
getAbsolutePresignPostUrl(key: string): Promise<PresignedUrl>;
/**
* Generate a presigned URL to download a file by key.
*/
getPresignGetUrl(key: string): Promise<PresignedUrl>;
/**
* Server-side upload of a file's bytes. Returns the chosen key.
*/
uploadFile(input: UploadFileInput): Promise<UploadFileResult>;
/**
* Server-side delete of a file by key.
*/
deleteFile(key: string): Promise<void>;
}
@@ -77,7 +77,8 @@ export const putFileServerSide = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.with('s3', async () => putFileInObjectStorage(file))
.with('azure-blob', async () => putFileInObjectStorage(file))
.otherwise(async () => putFileInDatabase(file));
};
@@ -94,7 +95,7 @@ const putFileInDatabase = async (file: File) => {
};
};
const putFileInS3 = async (file: File) => {
const putFileInObjectStorage = async (file: File) => {
const buffer = await file.arrayBuffer();
const blob = new Blob([buffer], { type: file.type });
+7 -5
View File
@@ -45,7 +45,8 @@ export const putFile = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.with('s3', async () => putFileInObjectStorage(file, {}))
.with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }))
.otherwise(async () => putFileInDatabase(file));
};
@@ -62,7 +63,7 @@ const putFileInDatabase = async (file: File) => {
};
};
const putFileInS3 = async (file: File) => {
const putFileInObjectStorage = async (file: File, extraHeaders: Record<string, string>) => {
const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, {
method: 'POST',
headers: {
@@ -82,16 +83,17 @@ const putFileInS3 = async (file: File) => {
const body = await file.arrayBuffer();
const reponse = await fetch(url, {
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...extraHeaders,
},
body,
});
if (!reponse.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${reponse.status}`);
if (!response.ok) {
throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`);
}
return {
+14 -137
View File
@@ -1,154 +1,31 @@
import path from 'node:path';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { env } from '@documenso/lib/utils/env';
import slugify from '@sindresorhus/slugify';
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
import { alphaid } from '../id';
import { getStorageProvider } from './providers';
export const getPresignPostUrl = async (fileName: string, contentType: string, userId?: number) => {
const client = getS3Client();
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
// Get the basename and extension for the file
const { name, ext } = path.parse(fileName);
let slugified = slugify(name);
// If the slugified name is empty or too long, generate a random string instead
//
// This is fine since we don't really need the filename in s3 since we store it
// in the database and can always get the original filename from there.
//
// The slugified name can be empty when a string contains only CJK or other
// special characters.
if (slugified.length === 0 || slugified.length > 100) {
slugified = alphaid(8);
}
let key = `${alphaid(12)}/${slugified}${ext}`;
if (userId) {
key = `${userId}/${key}`;
}
const putObjectCommand = new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(client, putObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
return getStorageProvider().getPresignPostUrl(fileName, contentType, userId);
};
export const getAbsolutePresignPostUrl = async (key: string) => {
const client = getS3Client();
const { getSignedUrl: getS3SignedUrl } = await import('@aws-sdk/s3-request-presigner');
const putObjectCommand = new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
});
const url = await getS3SignedUrl(client, putObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
return getStorageProvider().getAbsolutePresignPostUrl(key);
};
export const getPresignGetUrl = async (key: string) => {
if (env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN')) {
const distributionUrl = new URL(key, `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN')}`);
const { getSignedUrl: getCloudfrontSignedUrl } = await import('@aws-sdk/cloudfront-signer');
const url = getCloudfrontSignedUrl({
url: distributionUrl.toString(),
keyPairId: `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID')}`,
privateKey: `${env('NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_CONTENTS')}`,
dateLessThan: new Date(Date.now() + ONE_HOUR).toISOString(),
});
return { key, url };
}
const client = getS3Client();
const { getSignedUrl: getS3SignedUrl } = await import('@aws-sdk/s3-request-presigner');
const getObjectCommand = new GetObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
});
const url = await getS3SignedUrl(client, getObjectCommand, {
expiresIn: ONE_HOUR / ONE_SECOND,
});
return { key, url };
return getStorageProvider().getPresignGetUrl(key);
};
/**
* Uploads a file to S3.
* Uploads a file server-side. Name preserved for backward compatibility with
* existing callers; underneath it delegates to the active storage provider.
*/
export const uploadS3File = async (file: File) => {
const client = getS3Client();
// Get the basename and extension for the file
const { name, ext } = path.parse(file.name);
const key = `${alphaid(12)}/${slugify(name)}${ext}`;
const fileBuffer = await file.arrayBuffer();
const response = await client.send(
new PutObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
Body: Buffer.from(fileBuffer),
ContentType: file.type,
}),
);
return { key, response };
const buffer = await file.arrayBuffer();
const { key } = await getStorageProvider().uploadFile({
name: file.name,
type: file.type,
body: buffer,
});
return { key };
};
export const deleteS3File = async (key: string) => {
const client = getS3Client();
await client.send(
new DeleteObjectCommand({
Bucket: env('NEXT_PRIVATE_UPLOAD_BUCKET'),
Key: key,
}),
);
};
const getS3Client = () => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
if (NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
throw new Error('Invalid upload transport');
}
const hasCredentials = env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID') && env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY');
return new S3Client({
endpoint: env('NEXT_PRIVATE_UPLOAD_ENDPOINT') || undefined,
forcePathStyle: env('NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE') === 'true',
region: env('NEXT_PRIVATE_UPLOAD_REGION') || 'us-east-1',
credentials: hasCredentials
? {
accessKeyId: String(env('NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID')),
secretAccessKey: String(env('NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY')),
}
: undefined,
});
return getStorageProvider().deleteFile(key);
};
+42
View File
@@ -597,6 +597,48 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
user: message,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED }, ({ data }) => {
const message = msg({
message: `External 2FA token issued for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED }, ({ data }) => {
const message = msg({
message: `External 2FA token issuance denied for recipient ${data.recipientEmail}: ${data.reasonCode}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED }, ({ data }) => {
const message = msg({
message: `External 2FA verification succeeded for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED }, ({ data }) => {
const message = msg({
message: `External 2FA verification failed for recipient ${data.recipientEmail}: ${data.reasonCode} (attempt ${data.attemptsUsed}/${data.attemptLimit})`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED }, ({ data }) => {
const message = msg({
message: `External 2FA token consumed for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED }, ({ data }) => {
const message = msg({
message: `External 2FA token revoked for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.exhaustive();
let selectedDescription = description.anonymous;
@@ -0,0 +1,60 @@
-- CreateEnum
CREATE TYPE "SigningTwoFactorTokenStatus" AS ENUM ('ACTIVE', 'CONSUMED', 'REVOKED', 'EXPIRED');
-- CreateTable
CREATE TABLE "SigningTwoFactorToken" (
"id" TEXT NOT NULL,
"recipientId" INTEGER NOT NULL,
"envelopeId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"tokenSalt" TEXT NOT NULL,
"status" "SigningTwoFactorTokenStatus" NOT NULL DEFAULT 'ACTIVE',
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"attempts" INTEGER NOT NULL DEFAULT 0,
"attemptLimit" INTEGER NOT NULL DEFAULT 5,
"issuedByApiTokenId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SigningTwoFactorToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SigningSessionTwoFactorProof" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"recipientId" INTEGER NOT NULL,
"envelopeId" TEXT NOT NULL,
"verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SigningSessionTwoFactorProof_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SigningTwoFactorToken_recipientId_envelopeId_status_idx" ON "SigningTwoFactorToken"("recipientId", "envelopeId", "status");
-- CreateIndex
CREATE INDEX "SigningTwoFactorToken_envelopeId_idx" ON "SigningTwoFactorToken"("envelopeId");
-- CreateIndex
CREATE INDEX "SigningSessionTwoFactorProof_recipientId_envelopeId_idx" ON "SigningSessionTwoFactorProof"("recipientId", "envelopeId");
-- CreateIndex
CREATE INDEX "SigningSessionTwoFactorProof_expiresAt_idx" ON "SigningSessionTwoFactorProof"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "SigningSessionTwoFactorProof_sessionId_recipientId_envelope_key" ON "SigningSessionTwoFactorProof"("sessionId", "recipientId", "envelopeId");
-- AddForeignKey
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+1 -1
View File
@@ -22,7 +22,7 @@
},
"dependencies": {
"@prisma/client": "^6.19.0",
"kysely": "0.28.16",
"kysely": "0.29.2",
"nanoid": "^5.1.6",
"prisma": "^6.19.0",
"prisma-extension-kysely": "^3.0.0",
+58
View File
@@ -437,6 +437,9 @@ model Envelope {
envelopeAttachments EnvelopeAttachment[]
signingTwoFactorTokens SigningTwoFactorToken[]
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
@@index([type])
@@index([status])
@@index([userId])
@@ -604,6 +607,9 @@ model Recipient {
fields Field[]
signatures Signature[]
signingTwoFactorTokens SigningTwoFactorToken[]
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
@@index([token])
@@index([email])
@@index([envelopeId])
@@ -1115,6 +1121,58 @@ model Counter {
value Int
}
enum SigningTwoFactorTokenStatus {
ACTIVE
CONSUMED
REVOKED
EXPIRED
}
model SigningTwoFactorToken {
id String @id @default(cuid())
recipientId Int
envelopeId String
tokenHash String
tokenSalt String
status SigningTwoFactorTokenStatus @default(ACTIVE)
expiresAt DateTime
consumedAt DateTime?
revokedAt DateTime?
attempts Int @default(0)
attemptLimit Int @default(5)
issuedByApiTokenId Int?
createdAt DateTime @default(now())
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
@@index([recipientId, envelopeId, status])
@@index([envelopeId])
}
model SigningSessionTwoFactorProof {
id String @id @default(cuid())
sessionId String
recipientId Int
envelopeId String
verifiedAt DateTime @default(now())
expiresAt DateTime
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
@@unique([sessionId, recipientId, envelopeId])
@@index([recipientId, envelopeId])
@@index([expiresAt])
}
model RateLimit {
key String
action String
+3 -1
View File
@@ -63,4 +63,6 @@ const main = async () => {
}
};
void main();
if (require.main === module) {
void main();
}
+1 -1
View File
@@ -11,7 +11,7 @@
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.18",
"tailwindcss-animate": "^1.0.7"
},
@@ -0,0 +1,32 @@
import { jobsClient } from '@documenso/lib/jobs/client';
import { createAdminUser } from '@documenso/lib/server-only/user/create-admin-user';
import { adminProcedure } from '../trpc';
import { ZCreateUserRequestSchema, ZCreateUserResponseSchema } from './create-user.types';
export const createUserRoute = adminProcedure
.input(ZCreateUserRequestSchema)
.output(ZCreateUserResponseSchema)
.mutation(async ({ input, ctx }) => {
const { email, name } = input;
const user = await createAdminUser({
name,
email,
});
ctx.logger.info({
createdUserId: user.id,
});
await jobsClient.triggerJob({
name: 'send.admin.user.created.email',
payload: {
userId: user.id,
},
});
return {
userId: user.id,
};
});
@@ -0,0 +1,15 @@
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { z } from 'zod';
export const ZCreateUserRequestSchema = z.object({
email: z.string().email().min(1),
name: ZNameSchema,
});
export type TCreateUserRequest = z.infer<typeof ZCreateUserRequestSchema>;
export const ZCreateUserResponseSchema = z.object({
userId: z.number(),
});
export type TCreateUserResponse = z.infer<typeof ZCreateUserResponseSchema>;
@@ -2,6 +2,7 @@ import { router } from '../trpc';
import { createAdminOrganisationRoute } from './create-admin-organisation';
import { createStripeCustomerRoute } from './create-stripe-customer';
import { createSubscriptionClaimRoute } from './create-subscription-claim';
import { createUserRoute } from './create-user';
import { deleteDocumentRoute } from './delete-document';
import { deleteOrganisationRoute } from './delete-organisation';
import { deleteAdminOrganisationMemberRoute } from './delete-organisation-member';
@@ -64,6 +65,7 @@ export const adminRouter = router({
},
user: {
get: getUserRoute,
create: createUserRoute,
update: updateUserRoute,
delete: deleteUserRoute,
enable: enableUserRoute,
@@ -34,6 +34,9 @@ import { saveAsTemplateRoute } from './save-as-template';
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
import { signEnvelopeFieldRoute } from './sign-envelope-field';
import { getSigningTwoFactorStatusRoute } from './signing-2fa/get-signing-two-factor-status';
import { issueSigningTwoFactorTokenRoute } from './signing-2fa/issue-signing-two-factor-token';
import { verifySigningTwoFactorTokenRoute } from './signing-2fa/verify-signing-two-factor-token';
import { signingStatusEnvelopeRoute } from './signing-status-envelope';
import { updateEnvelopeRoute } from './update-envelope';
import { updateEnvelopeItemsRoute } from './update-envelope-items';
@@ -97,5 +100,10 @@ export const envelopeRouter = router({
saveAsTemplate: saveAsTemplateRoute,
distribute: distributeEnvelopeRoute,
redistribute: redistributeEnvelopeRoute,
signing2fa: {
issue: issueSigningTwoFactorTokenRoute,
verify: verifySigningTwoFactorTokenRoute,
getStatus: getSigningTwoFactorStatusRoute,
},
signingStatus: signingStatusEnvelopeRoute,
});
@@ -176,6 +176,7 @@ export const signEnvelopeFieldRoute = procedure
field,
userId: user?.id,
authOptions,
recipientToken: token,
});
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
@@ -0,0 +1,45 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getSigningTwoFactorStatus } from '@documenso/lib/server-only/signing-2fa/get-signing-two-factor-status';
import { prisma } from '@documenso/prisma';
import { procedure } from '../../trpc';
import {
ZGetSigningTwoFactorStatusRequestSchema,
ZGetSigningTwoFactorStatusResponseSchema,
} from './get-signing-two-factor-status.types';
export const getSigningTwoFactorStatusRoute = procedure
.input(ZGetSigningTwoFactorStatusRequestSchema)
.output(ZGetSigningTwoFactorStatusResponseSchema)
.query(async ({ input, ctx }) => {
const { token } = input;
ctx.logger.info({
input: {
token: '***',
},
});
const recipient = await prisma.recipient.findFirst({
where: {
token,
},
select: {
id: true,
envelopeId: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
statusCode: 404,
});
}
return await getSigningTwoFactorStatus({
recipientId: recipient.id,
envelopeId: recipient.envelopeId,
sessionId: token,
});
});

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