Compare commits

...

36 Commits

Author SHA1 Message Date
ephraimduncan ae07df6061 feat(team): add team analytics dashboard
Add a team document-usage dashboard at /t/:teamUrl/analytics for team admins and managers,
behind the NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED rollout flag (enabled by default, set to
"false" to gate it off).

Backend:
- getTeamAnalytics Kysely query over team-produced documents across all folders, with exact
  COUNT(*) (no STATS_COUNT_CAP). Each metric uses its own date axis: Sent/Draft/Pending by
  createdAt, Completed by Envelope.completedAt, Declined by the DOCUMENT_RECIPIENT_REJECTED
  audit-log timestamp.
- resolveAnalyticsPeriod turns calendar presets into half-open [start, end) ranges in the
  viewer's timezone, falling back to UTC.
- team.getAnalytics tRPC route gated to ADMIN/MANAGER.

Frontend:
- Standalone /t/:teamUrl/analytics route whose loader gates the flag and role, silently
  redirecting members to documents.
- Headline metrics and compact stat tiles, a member multiselect filter, a calendar-preset
  period selector, and an empty state.
- Role- and flag-gated nav entries in the desktop and mobile navigation.

Tests:
- Unit tests for the period resolver (timezone and preset boundaries).
- Integration/E2E tests for the query semantics (date axes, audit-log decline, all-folders
  aggregation, sender attribution), access control, filters and the empty state.
2026-05-29 13:52:29 +00:00
Lucas Smith 22ceff43e3 feat: admin-configurable email blocklist (#2884) 2026-05-29 01:12:55 +10:00
Lucas Smith a84da2f2c7 chore: disabled account enforcement (#2882) 2026-05-28 22:19:13 +10:00
Lucas Smith 7e8da85bd8 feat: block disposable email signups (#2883)
Reject disposable / throwaway email providers (mailinator, yopmail,
10minutemail, ...) across all signup paths: email/password, Google,
Microsoft, personal OIDC and organisation OIDC. Backed by the
mailchecker package (offline, ~55k domains, subdomain-aware).

Exposes a SIGNUP_DISPOSABLE_EMAIL error code so the signup form and
SSO redirect alert can show a dedicated message instead of the
generic 'signup disabled' one.
2026-05-28 21:15:27 +09:00
David Nguyen d304d8720c fix: add temp email rate limit (#2879) 2026-05-28 17:09:09 +10: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
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
130 changed files with 6026 additions and 2305 deletions
@@ -0,0 +1,75 @@
---
date: 2026-05-29
title: Team Analytics Dashboard
---
> Source: issue #242 (documenso/backlog-internal, "Team signing analytics/dashboard"). Redo of stale PR #1976 (`feat/team-dashboard`, closed DIRTY) — build fresh on `main`; do NOT resurrect that branch or its parallel `analytics/` module. Scope locked via interview 2026-05-29.
## V1 decisions (locked)
| Topic | Decision |
|---|---|
| Goal | Team **document usage** dashboard. NOT signature / recipient / user metrics; no "cumulative active users". |
| Headline | **Documents Sent** (non-draft) + **Completed**. Raw counts only — no completion-rate or derived metrics, no period-over-period deltas. |
| State tiles | Draft · Pending · Completed · **Declined (= `REJECTED`)**. "Voided" dropped (no `VOIDED` status exists). Render as a **row of compact stat tiles** (big number + small label). |
| Attribution | By document **owner** (`Envelope.userId`, reuse `senderIds`). Full per-member, **no** privacy guardrail. |
| Member control | **Filter only** — multiselect of team members + easy "All"; every number reflects the selected subset. Reuse `documents-table-sender-filter`. |
| Time filter | **Calendar presets** (This week / This month / This quarter / This year, Last month, …). Default **This month / last 30 days**. **No per-bucket breakdown, no trend chart** — one total per metric for the range. Boundaries in the **viewer's local timezone**. |
| Folder scope | Aggregate **all folders**. Folder filter control → V2. |
| Counts | **Exact** — drop `STATS_COUNT_CAP` on the analytics path. |
| Freshness | **Live** on every load (no cache in V1). |
| Number format | Full, thousands-separated (`1,234`). |
| Inbox | **Excluded** — dashboard = docs the team PRODUCES, not receives. |
| Placement | Standalone top-level route `/t/:teamUrl/analytics` + **primary nav entry**. |
| Access | `ADMIN` + `MANAGER` only. `MEMBER`**hide nav + silent redirect** to documents (no 403, no existence leak). |
| Rollout | Behind a **feature flag**, then open to all teams. No plan/tier gating. |
| Empty state | Friendly empty state with CTA to send the first document. |
| Export | Out of V1 (gold-plating). |
| Deferred → V2 | Folder filter, personal/individual analytics, org-wide cross-team rollup, trend charts, period deltas, derived metrics. |
## Metric semantics — READ THIS
**Event model, bucketed by status date.** Each number counts documents that ENTERED that state during the selected period, each on its OWN date axis:
- **Documents Sent** = non-draft, `createdAt` ∈ period. *(createdAt is the sent-date proxy — see Risks.)*
- **Pending** = currently `PENDING`, `createdAt` ∈ period (sent in period, still pending).
- **Completed** = status `COMPLETED`, **`Envelope.completedAt`** ∈ period.
- **Declined** = status `REJECTED`, rejection time ∈ period — sourced from **`DocumentAuditLog` `DOCUMENT_RECIPIENT_REJECTED`** (there is no `Envelope.rejectedAt`).
- **Draft** = currently `DRAFT`, `createdAt` ∈ period (informational; excluded from "Sent").
⚠️ **Tiles are independent activity counts on different date axes → they do NOT sum to "Documents Sent."** A doc sent in April but completed in May lands in May's Completed, not April's. The UI must NOT present the tiles as if they add up to the headline (no "x of y" framing, no stacked-total visuals).
## Backend
- **New dedicated analytics query** (e.g. `packages/lib/server-only/team/get-team-analytics.ts`). **Do NOT call `getStats` directly** — its semantics diverge (root-folder-only, `STATS_COUNT_CAP`-capped, every status bucketed by `createdAt`). **Reuse its *patterns*:** Kysely builder, team `visibilityFilter` / `teamDeletedFilter`, `senderIds`, `EnvelopeType.DOCUMENT`, `deletedAt IS NULL`.
- Per-metric windows: `createdAt` for Sent/Pending/Draft; `Envelope.completedAt` for Completed; join `DocumentAuditLog` (`type = DOCUMENT_RECIPIENT_REJECTED`, by `envelopeId`, earliest timestamp) for Declined. Exact `COUNT(*)` — no cap.
- Period: resolve `[start, end)` from preset + **viewer timezone** (client sends IANA zone/offset; default UTC if absent). Use Luxon (already imported in `get-stats.ts`).
- tRPC: new `team.getAnalytics` (team-router per-file pattern, `packages/trpc/server/team-router/`). Input `{ teamId, period | { from, to }, senderIds[] }`. **Gate `ADMIN`/`MANAGER`** via team role (`getTeamById``currentTeamRole`); deny `MEMBER`.
## Frontend
- Route `apps/remix/app/routes/_authenticated+/t.$teamUrl+/analytics._index.tsx`. Loader resolves team + role; `MEMBER``redirect` to `/t/:teamUrl/documents`. Behind feature flag (hidden + redirect when off).
- Components (tight, glanceable): headline (Documents Sent, Completed) + compact tile row (Draft/Pending/Completed/Declined); member multiselect (reuse `documents-table-sender-filter`, "All"); calendar-preset period selector; empty state.
- Nav entry in `apps/remix/app/components/general/menu-switcher.tsx`, gated by role + flag.
## Design
1. **Match existing Documenso UI** — Shadcn + Tailwind cards/typography from documents index & admin stats; reuse primitives, don't invent.
2. **Consult the `uidotsh` (`/ui`) skill for EVERY design decision.** No layout/spacing/component/visual choice ships without first fetching `uidotsh://ui`, routing to the matching subskill, and loading its design-guideline files before writing markup. Subskills: `ideas` (tile-row layout options), `design` (`design-guidelines.md`), `finalize` = `componentize` + `canonicalize-tailwind`, `add-dark-mode`, `make-responsive`.
## Approach order
Per ElTimuro: **iterate on the UI first, then finalize the backend.** Stand up the page + filters against a thin query, agree the tile layout via `uidotsh`, then lock the analytics query (date axes, audit-log join, exact counts).
## Verification
- Unit (`get-team-analytics`): doc sent-April/completed-May counts in May's Completed only (not April); Declined dated from audit log; all-folders aggregation; exact counts beyond the old cap; `senderIds` attribution; timezone-correct period boundaries.
- Unit (route/router): `ADMIN`/`MANAGER` allowed, `MEMBER` denied/redirected; flag off → nav hidden + redirect.
- E2E (Playwright, `@documenso/app-tests`): admin sees dashboard; member redirected away; member-filter and period-preset changes move the numbers; new team shows empty state.
## Risks / open
1. **Sent-date proxy:** `createdAt` ≠ true send time for draft-then-sent docs. More accurate = `DocumentAuditLog DOCUMENT_SENT`. V1 uses `createdAt` (no extra join); revisit if attribution looks wrong.
2. **Audit-log join cost** for Declined on large teams (live + uncapped). Acceptable for V1; caching/precompute is the V2 lever if it bites.
3. **Non-summing tiles** can confuse stakeholders — needs clear labels/tooltip; confirm framing with ElTimuro on the UI pass.
4. **Timezone plumbing:** client must send its zone and the server must bucket in it. Confirm no existing team-timezone setting should take precedence.
+2
View File
@@ -160,6 +160,8 @@ NEXT_PRIVATE_REDIS_PREFIX="documenso"
NEXT_PUBLIC_POSTHOG_KEY=""
# OPTIONAL: Leave blank to disable billing.
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Team analytics dashboard kill-switch. Enabled by default; set to "false" to hide it during rollout.
NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED=
# OPTIONAL: Set to "true" to disable all signup methods (email, Google, Microsoft, OIDC, including the organisation OIDC portal).
NEXT_PUBLIC_DISABLE_SIGNUP=
# OPTIONAL: Set to "true" to disable email/password signup only.
+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>
+20 -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';
@@ -49,6 +49,7 @@ export const ZSignUpFormSchema = z
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
SIGNUP_DISPOSABLE_EMAIL: msg`Disposable email addresses are not allowed. Please sign up with a permanent email address.`,
[AppErrorCode.ALREADY_EXISTS]: msg`We were unable to create your account. If you already have an account, try signing in instead.`,
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
};
@@ -86,8 +87,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 +104,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 +155,6 @@ export const SignUpForm = ({
});
turnstileRef.current?.reset();
setCaptchaToken(null);
}
};
@@ -316,11 +330,9 @@ export const SignUpForm = ({
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
appearance: 'always',
}}
/>
)}
@@ -0,0 +1,171 @@
import {
SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
type TSiteSettingsEmailBlocklistSchema,
} from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
const ZEmailBlocklistFormSchema = z.object({
enabled: z.boolean(),
domains: z.string(),
});
type TEmailBlocklistFormSchema = z.infer<typeof ZEmailBlocklistFormSchema>;
/**
* Splits a comma-separated string into a normalised list of domains.
* Normalisation (trim, lowercase, strip leading "@", dedupe) is applied
* server-side by the schema as well — this is for display consistency.
*/
const parseDomainsInput = (value: string): string[] => {
return Array.from(
new Set(
value
.split(',')
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ''))
.filter((entry) => entry.length > 0),
),
);
};
type AdminEmailBlocklistSectionProps = {
emailBlocklist: TSiteSettingsEmailBlocklistSchema | undefined;
};
export const AdminEmailBlocklistSection = ({ emailBlocklist }: AdminEmailBlocklistSectionProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TEmailBlocklistFormSchema>({
resolver: zodResolver(ZEmailBlocklistFormSchema),
defaultValues: {
enabled: emailBlocklist?.enabled ?? false,
domains: (emailBlocklist?.data?.domains ?? []).join(', '),
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBlocklistUpdate = async ({ enabled, domains }: TEmailBlocklistFormSchema) => {
try {
const parsedDomains = parseDomainsInput(domains);
await updateSiteSetting({
id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
enabled,
data: {
domains: parsedDomains,
},
});
// Reflect the normalised value back in the form.
form.reset({
enabled,
domains: parsedDomains.join(', '),
});
toast({
title: _(msg`Email Blocklist Updated`),
description: _(msg`The email blocklist has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the email blocklist. Please try again later.`,
),
});
}
};
return (
<div>
<h2 className="font-semibold">
<Trans>Email Blocklist</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
Block signups from additional email domains on top of the bundled disposable email list. Subdomains are
matched automatically (e.g. blocking "bad.com" also blocks "foo.bad.com").
</Trans>
</p>
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBlocklistUpdate)}>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="mt-4" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="domains"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Blocked Domains</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" placeholder="bad.com, spam.net, throwaway.io" {...field} />
</FormControl>
<FormDescription>
<Trans>Comma-separated list of email domains to block from signing up.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Blocklist</Trans>
</Button>
</form>
</Form>
</div>
);
};
@@ -0,0 +1,197 @@
import {
SITE_SETTINGS_BANNER_ID,
type TSiteSettingsBannerSchema,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { useCspNonce } from '~/utils/nonce';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
type AdminSiteBannerSectionProps = {
banner: TSiteSettingsBannerSchema | undefined;
};
export const AdminSiteBannerSection = ({ banner }: AdminSiteBannerSectionProps) => {
const nonce = useCspNonce();
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to display important
information to your users.
</Trans>
</p>
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBannerUpdate)}>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="flex flex-col gap-4 md:flex-row" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
);
};
@@ -0,0 +1,65 @@
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useMemo } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
const DEFAULT_PERIOD = 'month';
const PERIOD_OPTIONS = [
{ value: 'week', label: msg`This week` },
{ value: 'month', label: msg`This month` },
{ value: 'quarter', label: msg`This quarter` },
{ value: 'year', label: msg`This year` },
{ value: 'lastMonth', label: msg`Last month` },
{ value: 'last7Days', label: msg`Last 7 days` },
{ value: 'last30Days', label: msg`Last 30 days` },
] as const;
export const AnalyticsPeriodSelector = () => {
const { _ } = useLingui();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const period = useMemo(() => {
const parsed = ZAnalyticsPeriodSchema.safeParse(searchParams?.get('period') ?? DEFAULT_PERIOD);
return parsed.success ? parsed.data : DEFAULT_PERIOD;
}, [searchParams]);
const onPeriodChange = (newPeriod: string) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('period', newPeriod);
if (newPeriod === DEFAULT_PERIOD) {
params.delete('period');
}
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
};
return (
<Select value={period} onValueChange={onPeriodChange}>
<SelectTrigger className="max-w-[200px] text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{PERIOD_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{_(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
@@ -1,5 +1,7 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -45,7 +47,7 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
return [];
}
return [
const links = [
{
href: `/t/${teamUrl}/documents`,
label: msg`Documents`,
@@ -55,6 +57,19 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
label: msg`Templates`,
},
];
if (
currentTeam &&
IS_TEAM_ANALYTICS_ENABLED() &&
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
) {
links.push({
href: `/t/${currentTeam.url}/analytics`,
label: msg`Analytics`,
});
}
return links;
}, [currentTeam, organisations]);
return (
@@ -1,7 +1,9 @@
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
@@ -57,7 +59,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
];
}
return [
const links = [
{
href: `/t/${teamUrl}/documents`,
text: t`Documents`,
@@ -66,6 +68,20 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
href: `/t/${teamUrl}/templates`,
text: t`Templates`,
},
];
if (
currentTeam &&
IS_TEAM_ANALYTICS_ENABLED() &&
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
) {
links.push({
href: `/t/${currentTeam.url}/analytics`,
text: t`Analytics`,
});
}
links.push(
{
href: '/inbox',
text: t`Inbox`,
@@ -74,7 +90,9 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
href: '/settings/profile',
text: t`Settings`,
},
];
);
return links;
}, [currentTeam, organisations]);
return (
@@ -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>
@@ -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,210 +1,36 @@
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import {
SITE_SETTINGS_BANNER_ID,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { SITE_SETTINGS_EMAIL_BLOCKLIST_ID } from '@documenso/lib/server-only/site-settings/schemas/email-blocklist';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { AdminEmailBlocklistSection } from '~/components/general/admin-email-blocklist-section';
import { AdminSiteBannerSection } from '~/components/general/admin-site-banner-section';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCspNonce } from '~/utils/nonce';
import type { Route } from './+types/site-settings';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
export async function loader() {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
const settings = await getSiteSettings();
return { banner };
const banner = settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID);
const emailBlocklist = settings.find((setting) => setting.id === SITE_SETTINGS_EMAIL_BLOCKLIST_ID);
return { banner, emailBlocklist };
}
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
const { banner } = loaderData;
export default function AdminSiteSettingsPage({ loaderData }: Route.ComponentProps) {
const { banner, emailBlocklist } = loaderData;
const nonce = useCspNonce();
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<SettingsHeader title={_(msg`Site Settings`)} subtitle={_(msg`Manage your site settings here`)} />
<div className="mt-8">
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to display important
information to your users.
</Trans>
</p>
<div className="mt-8 space-y-12">
<AdminSiteBannerSection banner={banner} />
<Form {...form}>
<form className="mt-4 flex flex-col rounded-md" onSubmit={form.handleSubmit(onBannerUpdate)}>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset className="flex flex-col gap-4 md:flex-row" disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} nonce={nonce} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isUpdateSiteSettingLoading} className="mt-4 justify-end self-end">
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
<AdminEmailBlocklistSection emailBlocklist={emailBlocklist} />
</div>
</div>
);
@@ -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>
@@ -0,0 +1,174 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { canExecuteTeamAction, formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useMemo } from 'react';
import { Link, redirect, useSearchParams } from 'react-router';
import { z } from 'zod';
import { AnalyticsPeriodSelector } from '~/components/general/analytics-period-selector';
import { CardMetric } from '~/components/general/metric-card';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/analytics._index';
export function meta() {
return appMetaTags(msg`Analytics`);
}
export async function loader({ request, params }: Route.LoaderArgs) {
// Behind a rollout flag: silently send everyone back to documents when off.
if (!IS_TEAM_ANALYTICS_ENABLED()) {
throw redirect(formatDocumentsPath(params.teamUrl));
}
const session = await getSession(request);
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
// Admins and managers only. Members are silently redirected (no existence leak).
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
throw redirect(formatDocumentsPath(params.teamUrl));
}
}
const ZSearchParamsSchema = z.object({
period: ZAnalyticsPeriodSchema.optional().catch(undefined),
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
});
export default function TeamAnalyticsPage() {
const { _ } = useLingui();
const team = useCurrentTeam();
const [searchParams] = useSearchParams();
const { period, senderIds } = useMemo(
() => ZSearchParamsSchema.parse(Object.fromEntries(searchParams.entries())),
[searchParams],
);
const timezone = useMemo(() => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return undefined;
}
}, []);
const { data, isLoading } = trpc.team.getAnalytics.useQuery({
teamId: team.id,
period,
timezone,
senderIds,
});
const analytics = data ?? {
sent: 0,
draft: 0,
pending: 0,
completed: 0,
declined: 0,
};
const hasActivity =
analytics.sent > 0 ||
analytics.draft > 0 ||
analytics.pending > 0 ||
analytics.completed > 0 ||
analytics.declined > 0;
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<h2 className="font-semibold text-4xl">
<Trans>Analytics</Trans>
</h2>
</div>
<div className="-m-1 flex flex-wrap items-center gap-x-4 gap-y-6 overflow-hidden p-1">
<DocumentsTableSenderFilter teamId={team.id} />
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<AnalyticsPeriodSelector />
</div>
</div>
</div>
<div className="mt-8">
{isLoading ? (
<SpinnerBox className="py-32" />
) : hasActivity ? (
<div data-testid="team-analytics-content">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div data-testid="metric-sent" className="contents">
<CardMetric title={_(msg`Documents Sent`)} value={analytics.sent} />
</div>
<div data-testid="metric-completed-headline" className="contents">
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-4 lg:grid-cols-4">
<CardMetric title={_(msg`Draft`)} value={analytics.draft} />
<CardMetric title={_(msg`Pending`)} value={analytics.pending} />
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
<CardMetric title={_(msg`Declined`)} value={analytics.declined} />
</div>
<p className="mt-3 max-w-3xl text-muted-foreground text-xs">
<Trans>
Each tile counts documents that entered that state during the selected period, on its own date. They are
independent activity counts and do not add up to Documents Sent.
</Trans>
</p>
</div>
) : (
<div
data-testid="team-analytics-empty"
className="flex flex-col items-center justify-center rounded-lg border border-border border-dashed py-20 text-center"
>
<h3 className="font-semibold text-foreground text-lg">
<Trans>No analytics to show yet</Trans>
</h3>
<p className="mt-2 max-w-md text-muted-foreground text-sm">
<Trans>
There's no document activity for the selected period. Send your first document to start tracking your
team's usage here.
</Trans>
</p>
<Button asChild className="mt-6">
<Link to={formatDocumentsPath(team.url)}>
<Trans>Send a document</Trans>
</Link>
</Button>
</div>
)}
</div>
</div>
);
}
@@ -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
+1577 -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();
});
@@ -0,0 +1,209 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getTeamAnalytics } from '@documenso/lib/server-only/team/get-team-analytics';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { prisma } from '@documenso/prisma';
import {
seedBlankDocument,
seedCompletedDocument,
seedDraftDocument,
seedPendingDocument,
seedTeamDocuments,
} from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { apiSignin, apiSignout } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
// Fixed, "now"-independent windows so the date-axis assertions are deterministic.
const SENT_IN_APRIL = new Date('2026-04-10T12:00:00.000Z');
const ACTIONED_IN_MAY = new Date('2026-05-10T12:00:00.000Z');
const APRIL = {
periodStart: new Date('2026-04-01T00:00:00.000Z'),
periodEnd: new Date('2026-05-01T00:00:00.000Z'),
};
const MAY = {
periodStart: new Date('2026-05-01T00:00:00.000Z'),
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
};
// ─── Query semantics (no browser / dev server) ───────────────────────────────
test('[ANALYTICS]: a completed document is counted by completedAt, not createdAt', async () => {
const { team, owner } = await seedTeam();
// Sent in April, completed in May — the document lands on two different axes.
await seedCompletedDocument(owner, team.id, [], {
createDocumentOptions: {
createdAt: SENT_IN_APRIL,
completedAt: ACTIONED_IN_MAY,
},
});
const april = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...APRIL });
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
// Created (and non-draft) in April → counts as Sent in April only.
expect(april.sent).toBe(1);
expect(april.completed).toBe(0);
// Completed in May → counts as Completed in May only, never as Sent in May.
expect(may.sent).toBe(0);
expect(may.completed).toBe(1);
});
test('[ANALYTICS]: declined documents are dated from the rejection audit log', async () => {
const { team, owner } = await seedTeam();
// Document was created in April but only rejected in May.
const rejected = await seedBlankDocument(owner, team.id, {
createDocumentOptions: {
status: DocumentStatus.REJECTED,
createdAt: SENT_IN_APRIL,
},
});
await prisma.documentAuditLog.create({
data: {
envelopeId: rejected.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
createdAt: ACTIONED_IN_MAY,
data: {},
},
});
const april = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...APRIL });
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
// Rejection happened in May, so April (the creation month) records no decline.
expect(april.declined).toBe(0);
expect(may.declined).toBe(1);
});
test('[ANALYTICS]: counts attribute by owner and aggregate across all folders', async () => {
const { team, owner, organisation } = await seedTeam({ createTeamMembers: 1 });
const member = organisation.members[1].user;
const folder = await seedBlankFolder(owner, team.id);
// Owner: one pending in the root folder, one pending nested in a folder.
await seedPendingDocument(owner, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
});
await seedPendingDocument(owner, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY, folderId: folder.id },
});
// Member: one pending in the root folder.
await seedPendingDocument(member, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
});
// All folders are aggregated: the nested document is included.
const everyone = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
expect(everyone.pending).toBe(3);
// Attribution by owner via senderIds.
const ownerOnly = await getTeamAnalytics({
userId: owner.id,
teamId: team.id,
senderIds: [owner.id],
...MAY,
});
expect(ownerOnly.pending).toBe(2);
const memberOnly = await getTeamAnalytics({
userId: owner.id,
teamId: team.id,
senderIds: [member.id],
...MAY,
});
expect(memberOnly.pending).toBe(1);
});
test('[ANALYTICS]: "Documents Sent" excludes drafts but counts every other status', async () => {
const { team, owner } = await seedTeam();
await seedDraftDocument(owner, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
});
await seedPendingDocument(owner, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY },
});
await seedCompletedDocument(owner, team.id, [], {
createDocumentOptions: { createdAt: ACTIONED_IN_MAY, completedAt: ACTIONED_IN_MAY },
});
const may = await getTeamAnalytics({ userId: owner.id, teamId: team.id, ...MAY });
expect(may.draft).toBe(1);
expect(may.pending).toBe(1);
expect(may.completed).toBe(1);
// Sent = non-draft created in the period (pending + completed), drafts excluded.
expect(may.sent).toBe(2);
});
// ─── Access control + dashboard UI (requires the running dev server) ──────────
test('[ANALYTICS]: a team admin sees the dashboard and filters move the numbers', async ({ page }) => {
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamOwner.email,
redirectPath: `/t/${team.url}/analytics`,
});
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
await expect(page.getByTestId('team-analytics-content')).toBeVisible();
// teamMember1 (1 completed) + teamMember2 (2 pending) = 3 non-draft documents sent.
await expect(page.getByTestId('metric-sent')).toContainText('3');
// Filtering to teamMember2 narrows the sent count to their 2 pending documents.
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
await page.waitForURL(/senderIds/);
await expect(page.getByTestId('metric-sent')).toContainText('2');
});
test('[ANALYTICS]: a team member is redirected away from the dashboard', async ({ page }) => {
const { team } = await seedTeam();
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await apiSignin({
page,
email: member.email,
redirectPath: `/t/${team.url}/analytics`,
});
// The loader silently redirects members back to documents (no 403, no leak).
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents`);
expect(page.url()).toContain(`/t/${team.url}/documents`);
expect(page.url()).not.toContain('/analytics');
await apiSignout({ page });
});
test('[ANALYTICS]: a team with no document activity shows the empty state', async ({ page }) => {
const { team, owner } = await seedTeam();
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/analytics`,
});
await expect(page.getByTestId('team-analytics-empty')).toBeVisible();
await expect(page.getByRole('link', { name: 'Send a document' })).toBeVisible();
await expect(page.getByTestId('team-analytics-content')).toHaveCount(0);
});
@@ -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);
});
@@ -18,6 +18,7 @@ export const AuthenticationErrorCode = {
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
SignupDisabled: 'SIGNUP_DISABLED',
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
// IncorrectIdentityProvider: 'INCORRECT_IDENTITY_PROVIDER',
// IncorrectPassword: 'INCORRECT_PASSWORD',
+2 -1
View File
@@ -14,7 +14,7 @@ import { AUTH_SESSION_LIFETIME } from '../../config';
*/
export type SessionUser = Pick<
User,
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature'
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled'
>;
export type SessionValidationResult =
@@ -86,6 +86,7 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
twoFactorEnabled: true,
roles: true,
signature: true,
disabled: true,
},
},
},
@@ -1,3 +1,4 @@
import { assertUserNotDisabledById } from '@documenso/lib/server-only/user/assert-user-not-disabled';
import type { Context } from 'hono';
import type { HonoAuthContext } from '../../types/context';
@@ -10,8 +11,15 @@ type AuthorizeUser = {
/**
* Handles creating a session.
*
* Refuses to issue a session for a disabled account. This is the single
* chokepoint shared by every sign-in path (email/password, passkey, OAuth,
* OIDC, organisation OIDC), so the guard belongs here rather than in each
* caller.
*/
export const onAuthorize = async (user: AuthorizeUser, c: Context<HonoAuthContext>) => {
await assertUserNotDisabledById({ userId: user.userId });
const metadata = c.get('requestMetadata');
const sessionToken = generateSessionToken();
@@ -1,6 +1,11 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import {
isDisposableEmail,
isEmailDomainAllowedForSignup,
isSignupEnabledForProvider,
} from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
@@ -132,6 +137,17 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
return c.redirect(errorUrl.toString(), 302);
}
// Reject disposable / throwaway email providers for new SSO users.
const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) {
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
return c.redirect(errorUrl.toString(), 302);
}
// Handle new user.
const createdUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
@@ -1,6 +1,7 @@
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
import { prisma } from '@documenso/prisma';
@@ -74,6 +75,17 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
return c.redirect(errorUrl.toString(), 302);
}
// Reject disposable / throwaway email providers for new SSO users.
const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) {
const errorUrl = new URL(formatOrganisationLoginUrl(orgUrl));
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
return c.redirect(errorUrl.toString(), 302);
}
userToLink = await prisma.user.create({
data: {
email: email,
+16 -7
View File
@@ -1,4 +1,8 @@
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
import {
isDisposableEmail,
isEmailDomainAllowedForSignup,
isSignupEnabledForProvider,
} from '@documenso/lib/constants/auth';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
@@ -18,6 +22,7 @@ import {
signupRateLimit,
verifyEmailRateLimit,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
@@ -167,12 +172,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
if (user.disabled) {
throw new AppError('ACCOUNT_DISABLED', {
message: 'Account disabled',
});
}
// The disabled check now lives inside `onAuthorize` so every sign-in path
// (password, passkey, OAuth, OIDC) shares the same enforcement.
await onAuthorize({ userId: user.id }, c);
return c.text('', 201);
@@ -214,6 +215,14 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
const additionalBlockedDomains = await getEmailBlocklistDomains();
if (isDisposableEmail(email, additionalBlockedDomains)) {
throw new AppError(AuthenticationErrorCode.SignupDisposableEmail, {
statusCode: 400,
});
}
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
throw err;
@@ -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;
+9
View File
@@ -15,6 +15,15 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
/**
* Team analytics dashboard rollout flag.
*
* Acts as a kill-switch: enabled by default and disabled only when the env var
* is explicitly set to "false". This keeps the feature available to all teams
* while leaving a single lever to gate it off during rollout.
*/
export const IS_TEAM_ANALYTICS_ENABLED = () => env('NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED') !== 'false';
export const API_V2_BETA_URL = '/api/v2-beta';
export const API_V2_URL = '/api/v2';
+50
View File
@@ -1,3 +1,4 @@
import MailChecker from 'mailchecker';
import { z } from 'zod';
import { env } from '../utils/env';
@@ -14,6 +15,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.',
});
@@ -120,6 +122,54 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => {
return allowedDomains.includes(emailDomain);
};
/**
* Check if the given email belongs to a known disposable / throwaway provider
* (e.g. mailinator, yopmail, 10minutemail, ...).
*
* Backed by the `mailchecker` package which bundles a static list of 55k+
* disposable domains. The check is offline and synchronous.
*
* Matching also covers subdomains (e.g. `foo.mailinator.com` resolves to
* `mailinator.com`).
*
* An optional `additionalBlockedDomains` list can be supplied to layer
* admin-configured custom domains on top of the bundled list. These are
* matched with the same subdomain-walking behaviour and are expected to be
* pre-normalised (trimmed + lowercased) by the caller.
*
* Returns `true` when the email is disposable and should be rejected.
* Email format validation is intentionally NOT performed here — that is
* handled by Zod upstream.
*/
export const isDisposableEmail = (email: string, additionalBlockedDomains: string[] = []): boolean => {
const domain = email.toLowerCase().split('@').pop();
if (!domain) {
return false;
}
const blacklist = MailChecker.blacklist();
const blocklist = new Set(additionalBlockedDomains);
let currentDomain: string | undefined = domain;
while (currentDomain) {
if (blacklist.has(currentDomain) || blocklist.has(currentDomain)) {
return true;
}
const nextDot = currentDomain.indexOf('.');
if (nextDot === -1) {
break;
}
currentDomain = currentDomain.slice(nextDot + 1);
}
return false;
};
/**
* Check if signup is enabled for the given provider.
* The master switch takes precedence over the per-provider flags.
+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
>;
@@ -16,6 +16,7 @@ import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '../../../constants/recipient-roles';
import { assertOrgEmailSendAllowed } from '../../../server-only/email/assert-org-email-send-allowed';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { updateRecipientNextReminder } from '../../../server-only/recipient/update-recipient-next-reminder';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
@@ -83,14 +84,15 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
return;
}
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { branding, emailLanguage, settings, organisationType, senderEmail, replyToEmail, organisationId } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const customEmail = envelope?.documentMeta;
const isDirectTemplate = envelope.source === DocumentSource.TEMPLATE_DIRECT_LINK;
@@ -162,6 +164,22 @@ export const run = async ({ payload, io }: { payload: TSendSigningEmailJobDefini
});
if (isRecipientEmailValidForSending(recipient)) {
const sendCheck = await assertOrgEmailSendAllowed({ organisationId });
if (!sendCheck.allowed) {
// TEMPORARY: silent drop on rate-limit hit. Job is consumed and NOT retried.
io.logger.warn({
msg: 'Recipient signing email dropped: org rate limit exceeded',
organisationId,
recipientId: recipient.id,
envelopeId: envelope.id,
reason: sendCheck.reason,
resetsAt: sendCheck.resetsAt,
});
return;
}
await io.runTask('send-signing-email', async () => {
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
@@ -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 = {
+5 -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,8 +49,9 @@
"ioredis": "^5.10.1",
"jose": "^6.1.2",
"konva": "^10.0.9",
"kysely": "0.28.16",
"kysely": "0.29.2",
"luxon": "^3.7.2",
"mailchecker": "^6.0.20",
"nanoid": "^5.1.6",
"oslo": "^0.17.0",
"p-map": "^7.0.4",
@@ -57,8 +59,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",
@@ -2,6 +2,7 @@ import { mailer } from '@documenso/email/mailer';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import { RECIPIENT_ROLE_TO_EMAIL_TYPE, RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -26,8 +27,10 @@ import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { assertOrgEmailSendAllowed } from '../email/assert-org-email-send-allowed';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertUserNotDisabled } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type ResendDocumentOptions = {
@@ -47,9 +50,14 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
id: true,
email: true,
name: true,
disabled: true,
},
});
// Refuse to resend on behalf of a disabled account. Guards
// document.redistribute / envelope.redistribute and the API v1 equivalent.
assertUserNotDisabled(user);
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
@@ -120,14 +128,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
return envelope;
}
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
const { branding, emailLanguage, organisationType, senderEmail, replyToEmail, organisationId } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
await Promise.all(
recipientsToRemind.map(async (recipient) => {
@@ -200,6 +209,15 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
}),
]);
const sendCheck = await assertOrgEmailSendAllowed({ organisationId });
if (!sendCheck.allowed) {
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
message: 'Organisation email send rate limit exceeded',
userMessage: 'Email send rate limit reached. Please try again in a few minutes.',
});
}
// Send email outside any transaction to avoid holding a connection
// open during network I/O.
await mailer.sendMail({
@@ -39,6 +39,7 @@ import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields';
import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type SendDocumentOptions = {
@@ -50,6 +51,11 @@ export type SendDocumentOptions = {
};
export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetadata }: SendDocumentOptions) => {
// Refuse to send on behalf of a disabled account. Guards distribute /
// redistribute / template-use routes, the bulk-send job, and direct
// templates that auto-send on creation.
await assertUserNotDisabledById({ userId });
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
type: EnvelopeType.DOCUMENT,
@@ -104,10 +110,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) {
@@ -0,0 +1,42 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import {
recipientEmailRateLimit1d,
recipientEmailRateLimit5m,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
type AssertOrgEmailSendAllowedOptions = {
organisationId: string;
};
type Result = { allowed: true } | { allowed: false; reason: '5m' | '1d'; resetsAt: Date };
/**
* TEMPORARY: rate-limit unsolicited recipient emails per organisation.
*
* Two layered windows: 100/5m and 1000/1d, both keyed to org id. Returns a
* result object so callers can choose to silently drop (job path) or throw
* (sync path).
*
* Remove this helper and all callers when the comprehensive abuse-prevention
* design lands. See .agents/plans/sharp-gold-wave-email-abuse-prevention.md
*/
export const assertOrgEmailSendAllowed = async (options: AssertOrgEmailSendAllowedOptions): Promise<Result> => {
// Self-hosted instances are not behind the SES cap.
if (!IS_BILLING_ENABLED()) {
return { allowed: true };
}
const ip = `org:${options.organisationId}`;
const fiveMinResult = await recipientEmailRateLimit5m.check({ ip });
if (fiveMinResult.isLimited) {
return { allowed: false, reason: '5m', resetsAt: fiveMinResult.reset };
}
const dailyResult = await recipientEmailRateLimit1d.check({ ip });
if (dailyResult.isLimited) {
return { allowed: false, reason: '1d', resetsAt: dailyResult.reset };
}
return { allowed: true };
};
@@ -66,6 +66,7 @@ export type EmailContextResponse = {
branding: BrandingSettings;
settings: Omit<OrganisationGlobalSettings, 'id'>;
claims: OrganisationClaim;
organisationId: string;
organisationType: OrganisationType;
senderEmail: {
name: string;
@@ -164,6 +165,7 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
),
settings: organisation.organisationGlobalSettings,
claims,
organisationId: organisation.id,
organisationType: organisation.type,
};
};
@@ -208,6 +210,7 @@ const handleTeamEmailContext = async (teamId: number) => {
branding: teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false),
settings: teamSettings,
claims,
organisationId: organisation.id,
organisationType: organisation.type,
};
};
@@ -37,6 +37,7 @@ import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../uti
import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { getTeamSettings } from '../team/get-team-settings';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
@@ -116,6 +117,11 @@ export const createEnvelope = async ({
internalVersion,
bypassDefaultRecipients = false,
}: CreateEnvelopeOptions) => {
// Refuse to create on behalf of a disabled account. Guards every route that
// funnels through here (document.create, envelope.use, template create,
// embedding template/document create, API v1) and the seed/job paths.
await assertUserNotDisabledById({ userId });
const {
type,
title,
@@ -97,3 +97,17 @@ export const fileUploadRateLimit = createRateLimit({
max: 20,
window: '1m',
});
// ---- Recipient email send (TEMPORARY: per-org abuse-prevention stopgap) ----
export const recipientEmailRateLimit5m = createRateLimit({
action: 'email.send.recipient.5m',
max: 100,
window: '5m',
});
export const recipientEmailRateLimit1d = createRateLimit({
action: 'email.send.recipient.1d',
max: 1500,
window: '1d',
});
@@ -0,0 +1,31 @@
import { prisma } from '@documenso/prisma';
import { SITE_SETTINGS_EMAIL_BLOCKLIST_ID, ZSiteSettingsEmailBlocklistSchema } from './schemas/email-blocklist';
/**
* Returns the list of admin-configured email domains that should be treated as
* disposable / blocked, in addition to the bundled `mailchecker` list.
*
* Returns an empty array when the setting has not been configured, is
* disabled, or fails to parse — so a misconfigured setting can never block
* signups outright.
*/
export const getEmailBlocklistDomains = async (): Promise<string[]> => {
const setting = await prisma.siteSettings.findFirst({
where: {
id: SITE_SETTINGS_EMAIL_BLOCKLIST_ID,
},
});
if (!setting || !setting.enabled) {
return [];
}
const parsed = ZSiteSettingsEmailBlocklistSchema.safeParse(setting);
if (!parsed.success) {
return [];
}
return parsed.data.data.domains;
};
@@ -1,9 +1,14 @@
import { z } from 'zod';
import { ZSiteSettingsBannerSchema } from './schemas/banner';
import { ZSiteSettingsEmailBlocklistSchema } from './schemas/email-blocklist';
import { ZSiteSettingsTelemetrySchema } from './schemas/telemetry';
export const ZSiteSettingSchema = z.union([ZSiteSettingsBannerSchema, ZSiteSettingsTelemetrySchema]);
export const ZSiteSettingSchema = z.union([
ZSiteSettingsBannerSchema,
ZSiteSettingsEmailBlocklistSchema,
ZSiteSettingsTelemetrySchema,
]);
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
@@ -0,0 +1,29 @@
import { z } from 'zod';
import { ZSiteSettingsBaseSchema } from './_base';
export const SITE_SETTINGS_EMAIL_BLOCKLIST_ID = 'email.blocklist-domains';
/**
* Normalises a single domain entry: trims whitespace, lowercases, strips
* a leading "@" if present (so users can paste either "bad.com" or "@bad.com").
*/
const normaliseDomain = (value: string): string => value.trim().toLowerCase().replace(/^@/, '');
const ZBlocklistDomainsSchema = z
.array(z.string())
.transform((values) => Array.from(new Set(values.map(normaliseDomain).filter((value) => value.length > 0))));
export const ZSiteSettingsEmailBlocklistSchema = ZSiteSettingsBaseSchema.extend({
id: z.literal(SITE_SETTINGS_EMAIL_BLOCKLIST_ID),
data: z
.object({
domains: ZBlocklistDomainsSchema.default([]),
})
.optional()
.default({
domains: [],
}),
});
export type TSiteSettingsEmailBlocklistSchema = z.infer<typeof ZSiteSettingsEmailBlocklistSchema>;
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveAnalyticsPeriod } from './analytics-period';
const iso = (date: Date) => date.toISOString();
describe('resolveAnalyticsPeriod', () => {
// Friday, 2026-05-15. May 2026 is EDT (UTC-4) in the US.
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-15T12:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('resolves "month" to the current calendar month, half-open, in UTC by default', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'month' });
expect(iso(start)).toBe('2026-05-01T00:00:00.000Z');
expect(iso(end)).toBe('2026-06-01T00:00:00.000Z');
});
it('anchors month boundaries to the viewer timezone', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'America/New_York' });
// Local midnight in EDT is 04:00 UTC.
expect(iso(start)).toBe('2026-05-01T04:00:00.000Z');
expect(iso(end)).toBe('2026-06-01T04:00:00.000Z');
});
it('shifts the window for a zone ahead of UTC', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'Asia/Tokyo' });
// JST is UTC+9, so local midnight is the previous day at 15:00 UTC.
expect(iso(start)).toBe('2026-04-30T15:00:00.000Z');
expect(iso(end)).toBe('2026-05-31T15:00:00.000Z');
});
it('falls back to UTC for an invalid timezone', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'month', timezone: 'Not/AZone' });
expect(iso(start)).toBe('2026-05-01T00:00:00.000Z');
expect(iso(end)).toBe('2026-06-01T00:00:00.000Z');
});
it('resolves "lastMonth" contiguous with the start of the current month', () => {
const lastMonth = resolveAnalyticsPeriod({ period: 'lastMonth' });
const thisMonth = resolveAnalyticsPeriod({ period: 'month' });
expect(iso(lastMonth.start)).toBe('2026-04-01T00:00:00.000Z');
expect(iso(lastMonth.end)).toBe('2026-05-01T00:00:00.000Z');
expect(iso(lastMonth.end)).toBe(iso(thisMonth.start));
});
it('resolves "quarter" to the current calendar quarter', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'quarter' });
expect(iso(start)).toBe('2026-04-01T00:00:00.000Z');
expect(iso(end)).toBe('2026-07-01T00:00:00.000Z');
});
it('resolves "year" to the current calendar year', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'year' });
expect(iso(start)).toBe('2026-01-01T00:00:00.000Z');
expect(iso(end)).toBe('2027-01-01T00:00:00.000Z');
});
it('resolves "last30Days" as a trailing 30-day window including today', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'last30Days' });
expect(iso(start)).toBe('2026-04-16T00:00:00.000Z');
expect(iso(end)).toBe('2026-05-16T00:00:00.000Z');
expect(end.getTime() - start.getTime()).toBe(30 * 24 * 60 * 60 * 1000);
});
it('resolves "last7Days" as a trailing 7-day window including today', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'last7Days' });
expect(iso(start)).toBe('2026-05-09T00:00:00.000Z');
expect(iso(end)).toBe('2026-05-16T00:00:00.000Z');
expect(end.getTime() - start.getTime()).toBe(7 * 24 * 60 * 60 * 1000);
});
it('resolves "week" to a 7-day ISO week starting Monday', () => {
const { start, end } = resolveAnalyticsPeriod({ period: 'week' });
// 2026-05-15 is a Friday; the ISO week starts Monday 2026-05-11.
expect(iso(start)).toBe('2026-05-11T00:00:00.000Z');
expect(iso(end)).toBe('2026-05-18T00:00:00.000Z');
expect(end.getTime() - start.getTime()).toBe(7 * 24 * 60 * 60 * 1000);
});
});
@@ -0,0 +1,84 @@
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { z } from 'zod';
/**
* Calendar period presets for the team analytics dashboard. Each resolves to a
* half-open `[start, end)` instant range in the viewer's timezone.
*
* Pure (zod + luxon, no Prisma) so it can be unit-tested without a database and
* shared with the request schema without pulling server-only code anywhere it
* should not go.
*/
export const ZAnalyticsPeriodSchema = z.enum([
'week',
'month',
'quarter',
'year',
'lastMonth',
'last7Days',
'last30Days',
]);
export type AnalyticsPeriod = z.infer<typeof ZAnalyticsPeriodSchema>;
export const DEFAULT_ANALYTICS_PERIOD: AnalyticsPeriod = 'month';
export type AnalyticsPeriodRange = {
start: Date;
end: Date;
};
/**
* Resolve a calendar preset into a half-open `[start, end)` instant range,
* anchored to the viewer's timezone.
*
* Falls back to UTC when the zone is missing or invalid so boundaries never
* silently drift to the server's local zone.
*/
export const resolveAnalyticsPeriod = ({
period,
timezone,
}: {
period: AnalyticsPeriod;
timezone?: string;
}): AnalyticsPeriodRange => {
const zoned = DateTime.now().setZone(timezone ?? 'utc');
const now = zoned.isValid ? zoned : DateTime.now().setZone('utc');
const { start, end } = match(period)
.with('week', () => ({
start: now.startOf('week'),
end: now.startOf('week').plus({ weeks: 1 }),
}))
.with('month', () => ({
start: now.startOf('month'),
end: now.startOf('month').plus({ months: 1 }),
}))
.with('quarter', () => ({
start: now.startOf('quarter'),
end: now.startOf('quarter').plus({ quarters: 1 }),
}))
.with('year', () => ({
start: now.startOf('year'),
end: now.startOf('year').plus({ years: 1 }),
}))
.with('lastMonth', () => ({
start: now.startOf('month').minus({ months: 1 }),
end: now.startOf('month'),
}))
.with('last7Days', () => ({
start: now.startOf('day').minus({ days: 6 }),
end: now.startOf('day').plus({ days: 1 }),
}))
.with('last30Days', () => ({
start: now.startOf('day').minus({ days: 29 }),
end: now.startOf('day').plus({ days: 1 }),
}))
.exhaustive();
return {
start: start.toJSDate(),
end: end.toJSDate(),
};
};
@@ -0,0 +1,167 @@
import { kyselyPrisma, prisma, sql } from '@documenso/prisma';
import type { DB } from '@documenso/prisma/generated/types';
import { DocumentStatus, EnvelopeType, TeamMemberRole } from '@prisma/client';
import type { ExpressionBuilder, SelectQueryBuilder } from 'kysely';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { getTeamById } from './get-team';
// Kysely query builder type for Envelope queries.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type EnvelopeQueryBuilder = SelectQueryBuilder<DB, 'Envelope', any>;
// Expression builder scoped to the Envelope table context.
type EnvelopeExpressionBuilder = ExpressionBuilder<DB, 'Envelope'>;
export type GetTeamAnalyticsOptions = {
userId: number;
teamId: number;
periodStart: Date;
periodEnd: Date;
senderIds?: number[];
};
export type TeamAnalytics = {
sent: number;
draft: number;
pending: number;
completed: number;
declined: number;
};
/**
* Compute team document-usage analytics for a `[periodStart, periodEnd)` range.
*
* Each metric counts documents that ENTERED its state during the period, on its
* own date axis (see the Documenso team analytics spec):
*
* - `sent` — non-draft documents created in the period.
* - `draft` — documents still in draft, created in the period.
* - `pending` — documents still pending, created in the period.
* - `completed` — completed documents whose `completedAt` falls in the period.
* - `declined` — rejected documents with a `DOCUMENT_RECIPIENT_REJECTED` audit
* log entry in the period (there is no `Envelope.rejectedAt`).
*
* The tiles do NOT sum to `sent`: a document sent in one month but completed the
* next lands in that next month's `completed`, never the first month's `sent`.
*
* Scope mirrors the established team document patterns (`EnvelopeType.DOCUMENT`,
* `deletedAt IS NULL`, team `visibilityFilter`) but is limited to documents the
* team PRODUCES (`teamId` + owner attribution). Inbox / documents received via a
* team email are intentionally excluded. All folders are aggregated. Counts are
* exact `COUNT(*)` — the `STATS_COUNT_CAP` used by `getStats` is not applied.
*/
export const getTeamAnalytics = async ({
userId,
teamId,
periodStart,
periodEnd,
senderIds,
}: GetTeamAnalyticsOptions): Promise<TeamAnalytics> => {
const user = await prisma.user.findFirstOrThrow({
where: { id: userId },
select: { id: true, email: true },
});
const team = await getTeamById({ userId, teamId });
const currentTeamRole = team.currentTeamRole ?? TeamMemberRole.MEMBER;
const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[currentTeamRole];
// Visibility: the viewer can see documents within their allowed visibilities,
// documents they own, or documents they are a recipient of.
const visibilityFilter = (eb: EnvelopeExpressionBuilder) =>
eb.or([
eb(
'Envelope.visibility',
'in',
allowedVisibilities.map((visibility) => sql.lit(visibility)),
),
eb('Envelope.userId', '=', user.id),
eb.exists(
eb
.selectFrom('Recipient')
.whereRef('Recipient.envelopeId', '=', 'Envelope.id')
.where('Recipient.email', '=', user.email)
.select(sql.lit(1).as('one')),
),
]);
// Base query: team-produced, non-deleted documents across all folders.
const buildBaseQuery = (): EnvelopeQueryBuilder => {
let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely
.selectFrom('Envelope')
.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT))
.where('Envelope.teamId', '=', team.id)
.where('Envelope.deletedAt', 'is', null)
.where(visibilityFilter);
if (senderIds && senderIds.length > 0) {
qb = qb.where('Envelope.userId', 'in', senderIds);
}
return qb;
};
const countEnvelopes = async (qb: EnvelopeQueryBuilder): Promise<number> => {
const result = await qb.select(({ fn }) => fn.count<number>('Envelope.id').as('count')).executeTakeFirstOrThrow();
return Number(result.count ?? 0);
};
// Documents Sent: any non-draft document created in the period.
const sentQuery = buildBaseQuery()
.where('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT))
.where('Envelope.createdAt', '>=', periodStart)
.where('Envelope.createdAt', '<', periodEnd);
// Draft: created in the period, still a draft.
const draftQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT))
.where('Envelope.createdAt', '>=', periodStart)
.where('Envelope.createdAt', '<', periodEnd);
// Pending: created in the period, still pending.
const pendingQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING))
.where('Envelope.createdAt', '>=', periodStart)
.where('Envelope.createdAt', '<', periodEnd);
// Completed: completed in the period (completedAt is a distinct date axis).
const completedQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED))
.where('Envelope.completedAt', '>=', periodStart)
.where('Envelope.completedAt', '<', periodEnd);
// Declined: rejected documents whose rejection was logged in the period.
const declinedQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED))
.where((eb) =>
eb.exists(
eb
.selectFrom('DocumentAuditLog')
.whereRef('DocumentAuditLog.envelopeId', '=', 'Envelope.id')
.where('DocumentAuditLog.type', '=', DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED)
.where('DocumentAuditLog.createdAt', '>=', periodStart)
.where('DocumentAuditLog.createdAt', '<', periodEnd)
.select(sql.lit(1).as('one')),
),
);
const [sent, draft, pending, completed, declined] = await Promise.all([
countEnvelopes(sentQuery),
countEnvelopes(draftQuery),
countEnvelopes(pendingQuery),
countEnvelopes(completedQuery),
countEnvelopes(declinedQuery),
]);
return {
sent,
draft,
pending,
completed,
declined,
};
};
@@ -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,48 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
/**
* Throws if the supplied user object is disabled.
*
* Synchronous variant for hot paths where the `disabled` field has already
* been loaded (e.g. TRPC middleware where the user comes from the session
* query or API token lookup).
*/
export const assertUserNotDisabled = (user: { disabled: boolean }): void => {
if (user.disabled) {
throw new AppError('ACCOUNT_DISABLED', {
message: 'Account disabled',
statusCode: 403,
});
}
};
export type AssertUserNotDisabledByIdOptions = {
userId: number;
};
/**
* Throws if the user with the given id does not exist or is disabled.
*
* Used as a defence-in-depth guard for sign-in chokepoints and server-side
* actions that should not be performed on behalf of a disabled account
* (e.g. creating or sending documents). It deliberately re-queries from the
* database rather than relying on cached context so a freshly-disabled user
* cannot continue to act through a stale session or token.
*/
export const assertUserNotDisabledById = async ({ userId }: AssertUserNotDisabledByIdOptions): Promise<void> => {
const user = await prisma.user.findFirst({
where: { id: userId },
select: { disabled: true },
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
statusCode: 404,
});
}
assertUserNotDisabled(user);
};
@@ -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;
};
@@ -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);
};
+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",

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