diff --git a/.env.example b/.env.example index aa6565f8c..5f3da7c1f 100644 --- a/.env.example +++ b/.env.example @@ -103,7 +103,7 @@ NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso" NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password" # [[SMTP]] -# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels +# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | resend | mailchannels NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" # OPTIONAL: Defines the host to use for sending emails. NEXT_PRIVATE_SMTP_HOST="127.0.0.1" @@ -180,6 +180,20 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP= NEXT_PUBLIC_DISABLE_OIDC_SIGNUP= # OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org). NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS= +# OPTIONAL: Set to "true" to disable all signin methods (email, Google, Microsoft, OIDC). +NEXT_PUBLIC_DISABLE_SIGNIN= +# OPTIONAL: Set to "true" to disable email/password signin only. Also closes /forgot-password and /reset-password. +NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN= +# OPTIONAL: Set to "true" to hide the Google signin button. +NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN= +# OPTIONAL: Set to "true" to hide the Microsoft signin button. +NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN= +# OPTIONAL: Set to "true" to hide the OIDC signin button. +NEXT_PUBLIC_DISABLE_OIDC_SIGNIN= +# OPTIONAL: When OIDC is the only enabled signin transport, /signin auto-redirects +# to the OIDC provider (rendering only a spinner). Set to "true" to disable this +# and keep showing the signin page. +NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT= # OPTIONAL: Set to true to use internal webapp url in browserless requests. NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 4fcde0ea3..ceee6933e 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -34,7 +34,7 @@ body: label: Browser [e.g., Chrome, Firefox] - type: input attributes: - label: Version [e.g., 2.0.1] + label: Version [e.g., 2.13.0] - type: checkboxes attributes: label: Please check the boxes that apply to this issue report. @@ -44,4 +44,3 @@ body: - label: I have included relevant environment information. - label: I have included any relevant screenshots. - label: I understand that this is a voluntary contribution and that there is no guarantee of resolution. - - label: I want to work on creating a PR for this issue if approved diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..4be27fb49 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/documenso/documenso/security/advisories/new + about: Please report security vulnerabilities privately via GitHub Security Advisories. Do not open a public issue. + - name: Questions & Discussions + url: https://github.com/documenso/documenso/discussions + about: Ask questions, share ideas, and discuss Documenso with the community. + - name: Discord + url: https://documen.so/discord + about: Chat with the community and the team. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index ffb788c23..ab21e8828 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -33,4 +33,3 @@ body: - label: I have explained the use case or scenario for this feature. - label: I have included any relevant technical details or design suggestions. - label: I understand that this is a suggestion and that there is no guarantee of implementation. - - label: I want to work on creating a PR for this issue if approved diff --git a/.github/ISSUE_TEMPLATE/improvement.yml b/.github/ISSUE_TEMPLATE/improvement.yml index de2983b67..424d54a53 100644 --- a/.github/ISSUE_TEMPLATE/improvement.yml +++ b/.github/ISSUE_TEMPLATE/improvement.yml @@ -15,17 +15,6 @@ body: description: 'Are there any additional context or information that might be relevant to the improvement suggestion.' validations: required: false - - type: dropdown - id: assignee - attributes: - label: 'Do you want to work on this improvement?' - multiple: false - options: - - 'No' - - 'Yes' - default: 0 - validations: - required: true - type: checkboxes attributes: label: 'Please check the boxes that apply to this improvement suggestion.' diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 66602d12b..4e6151b34 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,14 @@ + + ## Description diff --git a/.github/PULL_REQUEST_TEMPLATE/test-addition.md b/.github/PULL_REQUEST_TEMPLATE/test-addition.md deleted file mode 100644 index f93c81493..000000000 --- a/.github/PULL_REQUEST_TEMPLATE/test-addition.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: Test Addition -about: Submit a new test, either unit or end-to-end (E2E), for review and inclusion ---- - -## Description - - - - -## Related Issue - - - - -## Test Details - - - - -- Test Name: Name of the test -- Type: [Unit / E2E] -- Description: Brief description of what the test checks -- Inputs: What inputs the test uses (if applicable) -- Expected Output: What output or behavior the test expects - -## Checklist - - - - -- [ ] I have written the new test and ensured it works as intended. -- [ ] I have added necessary documentation to explain the purpose of the test. -- [ ] I have followed the project's testing guidelines and coding style. -- [ ] I have addressed any review feedback from previous submissions, if applicable. - -## Additional Notes - - - diff --git a/.github/workflows/first-interaction.yml b/.github/workflows/first-interaction.yml index 5f53eb280..a4d7c8a54 100644 --- a/.github/workflows/first-interaction.yml +++ b/.github/workflows/first-interaction.yml @@ -1,13 +1,10 @@ name: 'Welcome New Contributors' on: - pull_request: - types: ['opened'] issues: types: ['opened'] permissions: - pull-requests: write issues: write jobs: @@ -20,10 +17,7 @@ jobs: - uses: actions/first-interaction@v1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - pr-message: | - Thank you for creating your first Pull Request and for being a part of the open signing revolution! 💚🚀 -
Feel free to hop into our community in [Discord](https://documen.so/discord) issue-message: | Thank you for opening your first issue and for being a part of the open signing revolution! -
One of our team members will review it and get back to you as soon as it possible 💚 +
One of our team members will review it and get back to you as soon as possible 💚
Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord) diff --git a/.github/workflows/issue-assignee-check.yml b/.github/workflows/issue-assignee-check.yml deleted file mode 100644 index de53564ec..000000000 --- a/.github/workflows/issue-assignee-check.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: 'Issue Assignee Check' - -on: - issues: - types: ['assigned'] - -permissions: - issues: write - -jobs: - countIssues: - if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User' - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - - - name: Install Octokit - run: npm install @octokit/rest@18 - - - name: Check Assigned User's Issue Count - id: parse-comment - uses: actions/github-script@v6 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { Octokit } = require("@octokit/rest"); - const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); - - const username = context.payload.issue.assignee.login; - console.log(`Username Extracted: ${username}`); - - const { data: issues } = await octokit.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - assignee: username, - state: 'open' - }); - - const issueCount = issues.length; - console.log(`Issue Count For ${username}: ${issueCount}`); - - if (issueCount > 3) { - let issueCountMessage = `### 🚨 Documenso Police 🚨`; - issueCountMessage += `\n@${username} has ${issueCount} open issues assigned already. Consider whether this issue should be assigned to them or left open for another contributor.`; - - await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: issueCountMessage, - headers: { - 'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`, - } - }); - } diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml deleted file mode 100644 index 67dc32f34..000000000 --- a/.github/workflows/pr-review-reminder.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: 'PR Review Reminder' - -on: - pull_request: - types: ['opened', 'ready_for_review'] - -permissions: - pull-requests: write - -jobs: - checkPRs: - if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review') - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - - - name: Install Octokit - run: npm install @octokit/rest@18 - - - name: Check user's PRs awaiting review - id: parse-prs - uses: actions/github-script@v5 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { Octokit } = require("@octokit/rest"); - const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); - - const username = context.payload.pull_request.user.login; - console.log(`Username Extracted: ${username}`); - - const { data: pullRequests } = await octokit.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - sort: 'created', - direction: 'asc', - }); - - const userPullRequests = pullRequests.filter(pr => pr.user.login === username && (pr.state === 'open' || pr.state === 'ready_for_review')); - const prCount = userPullRequests.length; - console.log(`PR Count for ${username}: ${prCount}`); - - if (prCount > 3) { - let prReminderMessage = `🚨 @${username} has ${prCount} pull requests awaiting review. Please consider reviewing them when possible. 🚨`; - - await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body: prReminderMessage, - headers: { - 'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`, - } - }); - } diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index 76a1b2f42..0dab3392d 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -16,24 +16,6 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - name: Check PR creator's previous activity - id: check_activity - run: | - CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login') - ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count') - if [ "$ACTIVITY" -eq 0 ]; then - echo "::set-output name=is_new::true" - else - echo "::set-output name=is_new::false" - fi - - - name: Count PRs created by user - id: count_prs - run: | - CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login') - PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count') - echo "::set-output name=pr_count::$PR_COUNT" - - uses: amannn/action-semantic-pull-request@v5 id: lint_pr_title env: @@ -44,8 +26,6 @@ jobs: with: header: pr-title-lint-error message: | - Hey There! and thank you for opening this pull request! 📝👋🏼 - We require pull request titles to follow the [Conventional Commits Spec](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. Details: @@ -53,10 +33,3 @@ jobs: ``` ${{ steps.lint_pr_title.outputs.error_message }} ``` - - - if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}} - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: pr-title-lint-error - message: | - Thank you for following the naming conventions for pull request titles! 💚🚀 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a18e33f87..c9c12ce59 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -21,4 +21,4 @@ jobs: stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.' close-pr-message: 'This PR has been closed because of inactivity.' exempt-pr-labels: 'WIP,on-hold,needs review' - exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage' + exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,status: assigned,status: triage' diff --git a/.gitpod.yml b/.gitpod.yml index 261f8c96b..883ac5bb3 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -31,8 +31,7 @@ vscode: extensions: - aaron-bond.better-comments - bradlc.vscode-tailwindcss - - dbaeumer.vscode-eslint - - esbenp.prettier-vscode + - biomejs.biome - mikestead.dotenv - unifiedjs.vscode-mdx - GitHub.vscode-pull-request-github diff --git a/.npmrc b/.npmrc index cbc6b6537..75baad7f0 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ legacy-peer-deps = true prefer-dedupe = true -min-release-age = 7 +# min-release-age = 7 diff --git a/.well-known/security.txt b/.well-known/security.txt index 1a3f685e5..f96fce0f0 100644 --- a/.well-known/security.txt +++ b/.well-known/security.txt @@ -1,7 +1,14 @@ -# General Issues +# Report security vulnerabilities privately via GitHub Security Advisories (preferred). +Contact: https://github.com/documenso/documenso/security/advisories/new + +# Alternatively, report critical issues privately by email. +Contact: mailto:security@documenso.com + +# Security policy +Policy: https://github.com/documenso/documenso/security/policy + +# General (non-security) issues Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml -# Report critical issues privately to let us take appropriate action before publishing. -Contact: mailto:security@documenso.com Preferred-Languages: en -Canonical: https://documenso.com/.well-known/security.txt \ No newline at end of file +Canonical: https://documenso.com/.well-known/security.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cd7a6887..7260cdfe2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,27 @@ # Contributing to Documenso -If you plan to contribute to Documenso, please take a moment to feel awesome ✨ People like you are what open source is about ♥. Any contributions, no matter how big or small, are highly appreciated. +> **We are no longer accepting external pull requests.** +> +> Aside from a small group of trusted contributors we reach out to directly, we no longer merge external PRs. New pull requests will usually be closed with a request to open an issue instead. This is a security decision, not a judgement on your work. Read [Why We're Pausing External Pull Requests](https://documenso.com/blog/why-we-re-pausing-external-pull-requests) for the full reasoning. +> +> Documenso stays open source. You can still read, audit, run, and fork the code. The best way to contribute is through detailed issues. -## Before getting started +## How to contribute now -- Before jumping into a PR be sure to search [existing PRs](https://github.com/documenso/documenso/pulls) or [issues](https://github.com/documenso/documenso/issues) for an open or closed item that relates to your submission. -- Select an issue from [here](https://github.com/documenso/documenso/issues) or create a new one -- 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. +The most useful contribution is a detailed issue. Treat it like a spec. The more detail, the better: + +- The problem you're trying to solve, and who it affects +- How you expect the feature or change to behave +- Edge cases, constraints, and anything you've already considered +- Examples, mockups, or references where they help + +Before opening an issue, search [existing issues](https://github.com/documenso/documenso/issues) and [discussions](https://github.com/documenso/documenso/discussions) for related items. If a proposal is detailed and fits where Documenso is heading, we'll pick it up and build against it. + +For security vulnerabilities, do not open a public issue. Follow our [Security Policy](./SECURITY.md) instead. + +--- + +The sections below are for trusted contributors working with us directly, and for anyone running Documenso locally or maintaining a fork. ## English only PRs and Issues diff --git a/README.md b/README.md index b52220554..642a285eb 100644 --- a/README.md +++ b/README.md @@ -51,16 +51,18 @@ Join us in creating the next generation of open trust infrastructure. ## Community and Next Steps 🎯 -- Check out the first source code release in this repository and test it. +- Try Documenso by self-hosting it or signing up at [documenso.com](https://documenso.com). - Tell us what you think in the [Discussions](https://github.com/documenso/documenso/discussions). -- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members. +- Join the [Discord server](https://documen.so/discord) for any questions and getting to know other community members. - ⭐ the repository to help us raise awareness. -- Spread the word on Twitter that Documenso is working towards a more open signing tool. -- Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release. +- Open detailed [issues](https://github.com/documenso/documenso/issues) to report bugs or propose features. ## Contributing -- To contribute, please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md). +> **Note**: We no longer accept external pull requests, aside from a small group of trusted contributors we reach out to directly. The best way to contribute is through detailed issues. Read [Why We're Pausing External Pull Requests](https://documenso.com/blog/why-we-re-pausing-external-pull-requests) for the reasoning. + +- Documenso stays open source. You can read, audit, run, and fork the code. +- To report issues or propose changes, see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md). ## Contact us @@ -81,17 +83,21 @@ Contact us if you are interested in our Enterprise plan for large organizations

-- [Typescript](https://www.typescriptlang.org/) - Language -- [ReactRouter](https://reactrouter.com/) - Framework +- [TypeScript](https://www.typescriptlang.org/) - Language +- [React Router v7](https://reactrouter.com/) - Framework +- [Hono](https://hono.dev/) - Server - [Prisma](https://www.prisma.io/) - ORM -- [Tailwind](https://tailwindcss.com/) - CSS -- [shadcn/ui](https://ui.shadcn.com/) - Component Library +- [Tailwind CSS](https://tailwindcss.com/) - CSS +- [shadcn/ui](https://ui.shadcn.com/) + [Radix UI](https://www.radix-ui.com/) - Component Library - [react-email](https://react.email/) - Email Templates +- [Lingui](https://lingui.dev/) - Internationalization - [tRPC](https://trpc.io/) - API -- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures (launching soon) -- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs -- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation +- [@libpdf/core](https://www.npmjs.com/package/@libpdf/core) - PDF Signatures +- [pdf.js](https://mozilla.github.io/pdf.js/) - Viewing PDFs +- [@cantoo/pdf-lib](https://github.com/cantoo-scribe/pdf-lib) - PDF manipulation - [Stripe](https://stripe.com/) - Payments +- [Biome](https://biomejs.dev/) - Linting & Formatting +- [Playwright](https://playwright.dev/) - E2E Testing @@ -196,6 +202,10 @@ For full instructions, requirements, and configuration details, see the [Self Ho [![Deploy on Elestio](https://elest.io/images/logos/deploy-to-elestio-btn.png)](https://elest.io/open-source/documenso) +## Security + +If you believe you have found a security vulnerability in Documenso, please report it through our [Security Policy](https://github.com/documenso/documenso/security/policy). We prioritize private reports via [GitHub Security Advisories](https://github.com/documenso/documenso/security/advisories/new). See [SECURITY.md](./SECURITY.md) for scope and details. + ## 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). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..7c672b928 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +We take the security of Documenso seriously. As a platform trusted with legally binding documents, the safety of the project and the people who rely on it is a priority for us. We're grateful to the security researchers who help keep it that way. If you've found an issue, we'd genuinely like to hear about it. + +## Reporting a Vulnerability + +Report security vulnerabilities privately. Do not open a public issue, discussion, or pull request for security reports. + +We accept reports through two channels, in order of preference: + +1. **GitHub Security Advisories (preferred)**. Use the [private vulnerability reporting form](https://github.com/documenso/documenso/security/advisories/new). This is our primary channel and lets us triage and work with you on a fix. +2. **Email**. If you cannot use GitHub Security Advisories, email [security@documenso.com](mailto:security@documenso.com). + +Include the affected version, a clear description, steps to reproduce, and the potential impact. + +## Triage and Response + +We triage reports as we have availability. We read every report we receive, and we appreciate the time and effort it takes to put one together. + +We also run [Codex](https://openai.com/codex/) security analysis across the codebase. If Codex has already reported the issue you're sending us, we may close your report as a duplicate. Please don't take this as a reflection on your work; it just means our automated tooling happened to surface the same thing first. + +## Scope + +This policy covers vulnerabilities in the Documenso application code in this repository. + +The items below are out of scope and will not be accepted. They are deployment, infrastructure, and configuration concerns that belong with the operator's firewall, network, and environment setup, not the application: + +- Server-Side Request Forgery (SSRF) and related network-egress concerns +- DNS rebinding and other DNS-level issues +- Rate limiting, denial of service, and volumetric attacks +- TLS and certificate configuration, HTTP security headers, and other reverse-proxy or web-server configuration +- Findings that depend on insecure self-hosted infrastructure or misconfiguration + +If you're unsure whether something is in scope, report it privately anyway and we'll happily take a look. + +## Supported Versions + +Security fixes are applied to the latest release. Run the most recent version of Documenso. diff --git a/SIGNING.md b/SIGNING.md index f794bc9ba..9aed0759a 100644 --- a/SIGNING.md +++ b/SIGNING.md @@ -1,67 +1,9 @@ -# Creating your own signing certificate +# Signing Certificate -For the digital signature of your documents you need a signing certificate in .p12 format (public and private key). You can buy one (not recommended for dev) or use the steps to create a self-signed one: +Documenso needs a signing certificate to digitally sign documents. For full, up-to-date instructions on generating, converting, and configuring a certificate, see the official documentation: -1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key: +- [Signing Certificate](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate): Overview and all certificate options +- [Local Certificate](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/local): Generate a self-signed `.p12` certificate with OpenSSL +- [Google Cloud HSM](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm): Sign using Google Cloud KMS - `openssl genrsa -out private.key 2048` - -2. Generate a self-signed certificate using the private key. You can run the following command to generate a self-signed certificate: - - `openssl req -new -x509 -key private.key -out certificate.crt -days 365` - - This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The `-days` parameter sets the number of days for which the certificate is valid. - -3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following commands to do this: - - ```bash - # Set certificate password securely (won't appear in command history) - read -s -p "Enter certificate password: " CERT_PASS - echo - - # Create the p12 certificate using the environment variable - openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt \ - -password env:CERT_PASS \ - -keypbe PBE-SHA1-3DES \ - -certpbe PBE-SHA1-3DES \ - -macalg sha1 - ``` - -4. **IMPORTANT**: A certificate password is required to prevent signing failures. Make sure to use a strong password (minimum 4 characters) when prompted. Certificates without passwords will cause "Failed to get private key bags" errors during document signing. - -5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created) - -## Docker - -> We are still working on the publishing of docker images, in the meantime you can follow the steps below to create a production ready docker image. - -Want to create a production ready docker image? Follow these steps: - -- cd into `docker` directory -- Make `build.sh` executable by running `chmod +x build.sh` -- Run `./build.sh` to start building the docker image. -- Publish the image to your docker registry of choice (or) If you prefer running the image from local, run the below command - -``` -docker run -d --restart=unless-stopped -p 3000:3000 -v documenso:/app/data --name documenso documenso:latest -``` - -Command Breakdown: - -- `-d` - Let's you run the container in background -- `-p` - Passes down which ports to use. First half is the host port, Second half is the app port. You can change the first half anything you want and reverse proxy to that port. -- `-v` - Volume let's you persist the data -- `--name` - Name of the container -- `documenso:latest` - Image you have built - -## Deployment - -We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates! - -## Railway - -[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic) - -## Render - -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso) +For deploying Documenso with Docker, 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. diff --git a/apps/docs/README.md b/apps/docs/README.md index 9b7bba9e0..770d32417 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -1,45 +1,16 @@ -# docs +# @documenso/docs -This is a Next.js application generated with -[Create Fumadocs](https://github.com/fuma-nama/fumadocs). +The Documenso documentation site, built with [Next.js](https://nextjs.org/) and [Fumadocs](https://fumadocs.dev/). Published at [docs.documenso.com](https://docs.documenso.com). -Run development server: +Content lives under `content/docs/` as MDX. See [WRITING_STYLE.md](../../WRITING_STYLE.md) for the documentation writing conventions. ```bash -npm run dev -# or -pnpm dev -# or -yarn dev +# From the monorepo root +npm run dev --filter=@documenso/docs ``` -Open http://localhost:3000 with your browser to see the result. +## Structure -## Explore - -In the project, you can see: - -- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content. -- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep. - -| Route | Description | -| ------------------------- | ------------------------------------------------------ | -| `app/(home)` | The route group for your landing page and other pages. | -| `app/docs` | The documentation layout and pages. | -| `app/api/search/route.ts` | The Route Handler for search. | - -### Fumadocs MDX - -A `source.config.ts` config file has been included, you can customise different options like frontmatter schema. - -Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details. - -## Learn More - -To learn more about Next.js and Fumadocs, take a look at the following -resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js - features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. -- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs +- `content/docs/`: Documentation pages (MDX). +- `lib/source.ts`: Content source adapter. +- `lib/layout.shared.tsx`: Shared layout options. diff --git a/apps/docs/content/docs/developers/api/rate-limits.mdx b/apps/docs/content/docs/developers/api/rate-limits.mdx index 0558ac79d..62f596860 100644 --- a/apps/docs/content/docs/developers/api/rate-limits.mdx +++ b/apps/docs/content/docs/developers/api/rate-limits.mdx @@ -65,3 +65,4 @@ When you exceed a resource limit: - [Authentication](/docs/developers/getting-started/authentication) - API authentication guide - [API Versioning](/docs/developers/api/versioning) - API version management - [First API Call](/docs/developers/getting-started/first-api-call) - Getting started with the API +- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Admins: set per-organisation resource quotas and rate limits (the HTTP rate limit above is separate and not admin-settable) diff --git a/apps/docs/content/docs/developers/local-development/index.mdx b/apps/docs/content/docs/developers/local-development/index.mdx index 5714a018b..3092da415 100644 --- a/apps/docs/content/docs/developers/local-development/index.mdx +++ b/apps/docs/content/docs/developers/local-development/index.mdx @@ -15,16 +15,17 @@ Pick the one that fits your needs the best. ## Tech Stack -- [Typescript](https://www.typescriptlang.org/) - Language -- [React Router](https://reactrouter.com/) - Framework +- [TypeScript](https://www.typescriptlang.org/) - Language +- [React Router v7](https://reactrouter.com/) - Framework +- [Hono](https://hono.dev/) - Server - [Prisma](https://www.prisma.io/) - ORM -- [Tailwind](https://tailwindcss.com/) - CSS -- [shadcn/ui](https://ui.shadcn.com/) - Component Library +- [Tailwind CSS](https://tailwindcss.com/) - CSS +- [shadcn/ui](https://ui.shadcn.com/) + [Radix UI](https://www.radix-ui.com/) - Component Library - [react-email](https://react.email/) - Email Templates +- [Lingui](https://lingui.dev/) - Internationalization - [tRPC](https://trpc.io/) - API -- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures -- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs -- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation +- [@libpdf/core](https://www.npmjs.com/package/@libpdf/core) - PDF Signing and Manipulation +- [pdf.js](https://mozilla.github.io/pdf.js/) - Viewing PDFs - [Stripe](https://stripe.com/) - Payments
diff --git a/apps/docs/content/docs/policies/enterprise-edition.mdx b/apps/docs/content/docs/policies/enterprise-edition.mdx index a611db5cd..f821e73ec 100644 --- a/apps/docs/content/docs/policies/enterprise-edition.mdx +++ b/apps/docs/content/docs/policies/enterprise-edition.mdx @@ -76,6 +76,8 @@ The Enterprise Edition is required when you: 4. Restart your Documenso instance 5. Verify the license is active in the **Admin Panel** under the **Stats** section + See [Apply Your License Key](/docs/self-hosting/configuration/license) for the full walkthrough, including how to enable individual features once licensed. + @@ -197,7 +199,7 @@ See [Support](/docs/policies/support) for complete support options. 1. Sign the Enterprise license agreement 2. Receive license key and access credentials 3. Deploy using [self-hosting guides](/docs/self-hosting) or access Documenso Cloud - 4. Configure Enterprise features with support assistance + 4. Apply the key — see [Apply Your License Key](/docs/self-hosting/configuration/license) — and configure Enterprise features with support assistance @@ -238,6 +240,7 @@ See [Support](/docs/policies/support) for complete support options. ## Related +- [Apply Your License Key](/docs/self-hosting/configuration/license) - Step-by-step license activation - [Community Edition](/docs/policies/community-edition) - AGPL-3.0 open-source license - [Licenses](/docs/policies/licenses) - Complete licensing overview and FAQ - [Support](/docs/policies/support) - Support channels and response times diff --git a/apps/docs/content/docs/self-hosting/configuration/email.mdx b/apps/docs/content/docs/self-hosting/configuration/email.mdx index bc7c729fa..d1c03e6cd 100644 --- a/apps/docs/content/docs/self-hosting/configuration/email.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/email.mdx @@ -278,7 +278,9 @@ Test your email configuration by creating an account or resetting a password. Th ### Using a Test SMTP Server -For development or testing, use a local SMTP server like [Mailhog](https://github.com/mailhog/MailHog) or [Mailpit](https://github.com/axllent/mailpit): +For development or testing, use a local SMTP server like [Inbucket](https://www.inbucket.org/), [Mailpit](https://github.com/axllent/mailpit), or [Mailhog](https://github.com/mailhog/MailHog). The default development setup (`docker/development/compose.yml`) already runs Inbucket, with its web UI on port 9000 and SMTP on port 2500. + +To run one standalone instead: ```bash # Using Docker diff --git a/apps/docs/content/docs/self-hosting/configuration/environment.mdx b/apps/docs/content/docs/self-hosting/configuration/environment.mdx index 33f723c31..bc4acd1a6 100644 --- a/apps/docs/content/docs/self-hosting/configuration/environment.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/environment.mdx @@ -86,6 +86,21 @@ Callback URL: `https:///api/auth/callback/microsoft` | `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | `false` | Skip email verification for OIDC accounts | | `NEXT_PRIVATE_OIDC_PROMPT` | `login` | OIDC prompt parameter. Set to empty string to omit | +### Webhooks + +| Variable | Default | Description | +| --------------------------------------- | ------- | ------------------------------------------------------------------------ | +| `NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` | - | Comma-separated hostnames or IPs allowed to resolve to private addresses | + +Before delivering a webhook, Documenso checks whether the target resolves to a +private or loopback address and blocks it if so. This check is best-effort and +fails open. Use `NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` to allow specific +internal hosts, for example when delivering to a service on your own network: + +```bash +NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS="hooks.internal.example,10.0.0.5" +``` + --- ## Email Configuration @@ -257,6 +272,12 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con | `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` | | `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` | | `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | | +| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch. Disable all signin methods application-wide | `false` | +| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin. Also closes `/forgot-password` and `/reset-password` | `false` | +| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` | +| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` | +| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` | +| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable the automatic `/signin` redirect when OIDC is the only enabled transport | `false` | | `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | | | `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` | @@ -288,6 +309,44 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true" NEXT_PUBLIC_DISABLE_SIGNUP="true" ``` +### Sign-in Restrictions + +You can control which methods are available for users to sign in with the following environment variables: + +- **`NEXT_PUBLIC_DISABLE_SIGNIN`** (master switch): Set to `true` to block all signin methods (email/password, Google, Microsoft, OIDC). Hides every signin entry point on `/signin` and rejects email/password signin server-side with a `SIGNIN_DISABLED` error. +- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN`**: Set to `true` to disable email/password signin only. The email/password form is hidden, the `/forgot-password` and `/reset-password` pages redirect to `/signin`, and the corresponding server endpoints reject requests. SSO signin is unaffected. +- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNIN`**: Set to `true` to hide the matching SSO button on the signin page. Useful when an SSO provider is kept configured for account linking but not advertised as a signin entry point. + +These flags are opt-in: when none are set, signin behaviour is unchanged from a stock Documenso instance. + +```bash +# Allow only OIDC signin (e.g. enterprise SSO-only) +NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true" +NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true" +NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true" + +# Or disable signin entirely +NEXT_PUBLIC_DISABLE_SIGNIN="true" +``` + +### OIDC Auto-redirect + +When OIDC is the only enabled signin transport on your instance, `/signin` automatically redirects users straight to the OIDC provider instead of showing the signin form. The page renders a spinner while the redirect happens. No extra configuration is required — disabling every other signin method is enough to trigger it. + +- **`NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT`**: Set to `true` to opt out of the automatic redirect and keep rendering the signin page even when OIDC is the only enabled transport. + +The redirect only triggers when OIDC is configured and email/password, Google, and Microsoft signin are all disabled. If any other transport remains enabled, the signin form is shown as normal. + +```bash +# OIDC-only signin: disabling all other methods auto-redirects to the provider +NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true" +NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true" +NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true" + +# Opt out of the auto-redirect while still OIDC-only +# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true" +``` + --- ## AI Features @@ -384,11 +443,11 @@ Telemetry collects only: app version, installation ID, and node ID. No personal ## Enterprise Features -These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. +These variables require an active [Enterprise Edition](/docs/policies/enterprise-edition) license. Obtain a license key from [license.documenso.com](https://license.documenso.com) and set it below to unlock enterprise features such as SSO, embed editor, and 21 CFR Part 11 compliance. See [Apply Your License Key](/docs/self-hosting/configuration/license) for step-by-step setup. | Variable | Description | | ------------------------------------ | ------------------------------------------------ | -| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features | +| `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` | License key for enterprise features — see [Apply Your License Key](/docs/self-hosting/configuration/license) for how to apply it | | `NEXT_PRIVATE_STRIPE_API_KEY` | Stripe API key for billing | | `NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET` | Stripe webhook secret | | `NEXT_PRIVATE_SES_ACCESS_KEY_ID` | AWS SES access key for email domain verification | @@ -431,6 +490,16 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" # NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true" # NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true" # NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org" + +# Sign-in restrictions (optional) +# NEXT_PUBLIC_DISABLE_SIGNIN="true" +# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true" +# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true" +# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true" +# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN="true" + +# Opt out of the automatic OIDC redirect when OIDC is the only enabled transport (optional) +# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true" ``` --- @@ -441,4 +510,5 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" - [Email Configuration](/docs/self-hosting/configuration/email) - Configure email delivery - [Storage Configuration](/docs/self-hosting/configuration/storage) - Set up S3 storage - [Signing Certificate](/docs/self-hosting/configuration/signing-certificate) - Configure document signing +- [Organisation Limits](/docs/self-hosting/configuration/organisation-limits) - Set per-organisation document, email, and API limits from the admin panel - [Troubleshooting](/docs/self-hosting/maintenance/troubleshooting) - Common configuration issues diff --git a/apps/docs/content/docs/self-hosting/configuration/index.mdx b/apps/docs/content/docs/self-hosting/configuration/index.mdx index 0e80e0efc..de5830a4d 100644 --- a/apps/docs/content/docs/self-hosting/configuration/index.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/index.mdx @@ -29,6 +29,11 @@ description: Configure your self-hosted Documenso instance with environment vari description="Digital signature certificate setup." href="/docs/self-hosting/configuration/signing-certificate" /> + ## Required Configuration diff --git a/apps/docs/content/docs/self-hosting/configuration/license.mdx b/apps/docs/content/docs/self-hosting/configuration/license.mdx new file mode 100644 index 000000000..eea50c154 --- /dev/null +++ b/apps/docs/content/docs/self-hosting/configuration/license.mdx @@ -0,0 +1,107 @@ +--- +title: Apply Your License Key +description: Activate your Enterprise license key to unlock enterprise features on your self-hosted instance. +--- + +import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; +import { Callout } from 'fumadocs-ui/components/callout'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; + +A license key activates the Enterprise features available to your self-hosted instance, such as CSC signing, SSO, embed white-labelling, and 21 CFR Part 11 compliance. + + + The license key applies to your **whole instance**, not an individual user account. There's one + key per deployment. + + +## Prerequisites + +- An active Enterprise license key — contact [sales](https://documen.so/enterprise) to set up an + Enterprise subscription, then copy your key from [license.documenso.com](https://license.documenso.com). + See [Enterprise Edition](/docs/policies/enterprise-edition) for details. +- A running self-hosted Documenso instance that you're able to restart + +## Step 1: Set the environment variable + +Set `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` to your license key. + + + + +Add the variable to your `.env` file (or directly under `environment:` in `compose.yml`): + +```bash +NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here +``` + +Then apply it: + +```bash +docker compose up -d +``` + + + + +```bash +docker run -d \ + --name documenso \ + -e NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here \ + documenso/documenso:latest +``` + + + + +If you're running Documenso directly (not in a container), add the variable to your `.env` file: + +```bash +NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY=your-license-key-here +``` + + + + +## Step 2: Restart the instance + +The license key is only read once, at process startup. Setting the variable in a running container or shell has no effect until the process restarts. + +```bash +# Docker Compose +docker compose restart documenso + +# Docker +docker restart documenso +``` + +On startup, Documenso validates the key against the Documenso license server and caches the result locally for future startups, so a brief license-server outage won't lock you out. + +## What the license enables + +A valid license doesn't turn every enterprise feature on everywhere — activation depends on the feature: + +- **CSC signing** activates instance-wide automatically once the license is active and CSC transport is configured. See [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) for the full setup. +- **SSO, embed white-labelling, 21 CFR Part 11, and similar** are provisioned per organisation. Follow each feature's own guide to configure it once the license is active. + +## Troubleshooting + + + + - Confirm the key is present in the environment the running process actually reads — `docker + exec` into the container and check `env | grep LICENSE` if unsure. + - Confirm the instance was fully restarted after the variable was set, not just reloaded. + - Re-copy the key to rule out truncation or accidental whitespace. + + + Instance-wide features (like CSC signing) also need their own configuration — an active license + alone isn't enough. Check that feature's guide to confirm the required settings are in place. + Per-organisation features additionally need to be provisioned for the organisation that's using + them. + + + +## See Also + +- [Environment Variables](/docs/self-hosting/configuration/environment) - Complete configuration reference +- [Enterprise Edition](/docs/policies/enterprise-edition) - What's included and how to purchase a license +- [CSC / QES Signing](/docs/self-hosting/configuration/signing-certificate/csc-qes) - Enable CSC-based signing diff --git a/apps/docs/content/docs/self-hosting/configuration/meta.json b/apps/docs/content/docs/self-hosting/configuration/meta.json index 32b92f853..6cc250f64 100644 --- a/apps/docs/content/docs/self-hosting/configuration/meta.json +++ b/apps/docs/content/docs/self-hosting/configuration/meta.json @@ -2,12 +2,14 @@ "title": "Configuration", "pages": [ "environment", + "license", "database", "email", "storage", "background-jobs", "signing-certificate", "telemetry", + "organisation-limits", "advanced" ] } diff --git a/apps/docs/content/docs/self-hosting/configuration/organisation-limits.mdx b/apps/docs/content/docs/self-hosting/configuration/organisation-limits.mdx new file mode 100644 index 000000000..2459812ce --- /dev/null +++ b/apps/docs/content/docs/self-hosting/configuration/organisation-limits.mdx @@ -0,0 +1,111 @@ +--- +title: Organisation Limits +description: View and set per-organisation document, email, and API limits on a self-hosted Documenso instance using the admin panel's subscription claims. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; + +Per-organisation limits — document, email, and API usage, plus feature toggles and team/member caps — are controlled by **subscription claims**. You configure them in the admin panel, not through environment variables. + +There are three distinct kinds of limit: + +| Limit | Caps | Admin-settable | +| ---------------------- | ------------------------------------------------- | ----------------------- | +| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org | +| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org | +| Global HTTP rate limit | API requests per IP (100/min, hardcoded) | No — see [Limitations](#limitations) | + +## Prerequisites + +- A running self-hosted Documenso instance. +- An account with the **`ADMIN`** role — an account-level role, separate from organisation and team roles. New accounts are created with the `USER` role only. Grant the first admin by adding `ADMIN` to that user's `roles` directly in the database; after that, an existing admin can grant the role to others under **Admin Panel > Users > _(user)_ > Roles > Update user**. + +Open the admin panel at `/admin`. The sidebar sections used below are **Claims**, **Organisations**, and **Organisation Stats**. + +## Viewing usage + +**One organisation:** open **Admin Panel > Organisations** and select it. The **Organisation usage** section shows the current period's document, email, and API usage against its quotas. + +**All organisations:** open **Admin Panel > Organisation Stats** to sort and filter monthly usage. Filter by **claim** and by **period** (a UTC calendar month, shown as `YYYY-MM`), and switch between **Show usage**, **Show usage with quotas**, and **Show daily averages**. + + + Usage counts **attempts**, not only successful actions. A request that exceeds a quota is still counted before it is rejected, so displayed usage can read higher than the number of actions that succeeded. + + +## Subscription claims + +A subscription claim is a named bundle of limits and feature flags (for example `Free`, `Individual`, `Teams`, `Platform`, or `Enterprise`). Claims are **templates**: when an organisation is created it receives a private copy of its claim and reads from that copy afterwards. Editing a claim template therefore affects organisations created later, not existing ones — to change an existing organisation, [edit it directly](#change-limits-for-one-organisation). + +### Claim fields + +Under **Admin Panel > Claims** (`/admin/claims`), each claim has: + +| Field | Controls | +| ----------------------- | --------------------------------------------------------------------------------- | +| **Name** | The claim's display name. | +| **Team Count** | Teams allowed. `0` = unlimited. | +| **Member Count** | Members allowed. `0` = unlimited. | +| **Envelope Item Count** | Uploaded files allowed per envelope. Minimum `1`. | +| **Recipient Count** | Recipients allowed per document. `0` = unlimited. | +| **Feature Flags** | Feature toggles (see [Feature flags](#feature-flags)). | +| **Limits** | Monthly quota and rate-limit windows for Documents, Emails, and API. | +| **Email transport** | Transport the claim uses. *Default (system mailer)* uses the instance default. | + +### Quotas and rate limits + +The **Limits** section has a column for **Documents**, **Emails**, and **API**, each with two controls: + +- **Monthly quota** — how many of that resource are allowed per calendar month. An **empty** field is unlimited; **`0`** blocks the resource entirely. +- **Rate limit windows** — optional short-window caps, each a duration and a maximum. A window is a number and a unit (`s`, `m`, `h`, `d`), such as `5m`, `1h`, or `24h`, and must be unique within the resource. + + + Quotas and counts use opposite conventions for "unlimited": an **empty** quota is unlimited (and `0` blocks the resource), whereas `0` in the **Team**, **Member**, and **Recipient Count** fields means unlimited. + + +### Feature flags + +The **Feature Flags** section toggles capabilities such as Unlimited documents, Branding, Hide Documenso branding, Email domains, Embed authoring, Embed signing, White label for embed authoring/signing, 21 CFR, HIPAA, Authentication portal, Allow Legacy Envelopes, Signing reminders, QES signing, and Disable emails. + +Some flags are Enterprise features. If your license does not include one, it is marked and cannot be enabled (you can still turn it off). See [Enterprise Edition](/docs/policies/enterprise-edition). + +### Create or edit a claim template + +1. Go to **Admin Panel > Claims**. +2. Select **New claim**, or select an existing claim to edit it. +3. Set the counts, feature flags, and the **Limits** section. +4. Save. Changes apply to organisations created afterwards, not existing ones. + +### Change limits for one organisation + +To change limits for an existing organisation, edit it directly rather than its claim template. + +1. Go to **Admin Panel > Organisations** and open the organisation. +2. Adjust its quota, rate-limit, feature-flag, or email-transport fields. +3. Save. Changes take effect immediately. + +The organisation also shows the **Inherited subscription claim** it was created from. + +## Usage reset + +Monthly quota usage is keyed to the **UTC calendar month**. There is no scheduled reset job — when the month rolls over, the new period's counter starts at `0`. + +## Limitations + +The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **100 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits). + +## Troubleshooting + +| Symptom | Cause and fix | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| An organisation hit its limit unexpectedly | Usage counts rejected over-quota attempts. Compare usage against the quota under **Organisation Stats > Show usage with quotas**. | +| A resource is blocked entirely, not just capped | The **Monthly quota** is `0`, which blocks the resource. Leave it empty for unlimited. | +| Emails are not sending for an organisation | Check whether the **Disable emails** flag is enabled on the organisation's claim — it blocks all emails regardless of quota. | +| A claim template edit had no effect | Template edits are not retroactive. Edit the organisation directly under **Admin Panel > Organisations**. | + +--- + +## See Also + +- [Environment Variables](/docs/self-hosting/configuration/environment) - All configuration options +- [Rate Limits](/docs/developers/api/rate-limits) - The global HTTP API rate limit (separate from claims) +- [Enterprise Edition](/docs/policies/enterprise-edition) - Features unlocked by license flags diff --git a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx index 0495430d7..4998baaf7 100644 --- a/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/signing-certificate/csc-qes.mdx @@ -49,7 +49,7 @@ The callback URL is fixed — Documenso derives it from `NEXT_PUBLIC_WEBAPP_URL` ### Enterprise Edition license -CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). +CSC mode is gated by the `instanceCscSigning` license flag. Without a valid Enterprise license, the transport refuses to start (`CSC_UNLICENSED`). See [Apply Your License Key](/docs/self-hosting/configuration/license) to activate one. diff --git a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx index b3590a7f2..84e228115 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx @@ -163,6 +163,19 @@ NEXT_PUBLIC_DISABLE_SIGNUP=false # NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true # NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true # NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org + +# Signin restrictions (optional) +# Master switch — disables every signin method +# NEXT_PUBLIC_DISABLE_SIGNIN=true +# Per-method switches (optional). Each disables that signin path. +# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=true +# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=true +# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=true +# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=true + +# When OIDC is the only enabled transport, /signin auto-redirects to the provider. +# Set this to opt out and keep showing the signin page (optional). +# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=true ``` Generate secure secrets using: `openssl rand -base64 32` diff --git a/apps/docs/content/docs/self-hosting/deployment/docker.mdx b/apps/docs/content/docs/self-hosting/deployment/docker.mdx index 7d6d4b9cd..68508e767 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker.mdx @@ -112,6 +112,12 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran | `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` | | `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` | | `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | | +| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` | +| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` | +| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` | +| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` | +| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` | +| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` | For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment). diff --git a/apps/docs/content/docs/self-hosting/deployment/kubernetes.mdx b/apps/docs/content/docs/self-hosting/deployment/kubernetes.mdx index d8adb8ac9..e7a18490f 100644 --- a/apps/docs/content/docs/self-hosting/deployment/kubernetes.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/kubernetes.mdx @@ -235,7 +235,7 @@ spec: ``` - Pin to a specific image tag (e.g., `documenso/documenso:1.5.0`) in production instead of `latest` + Pin to a specific image tag (e.g., `documenso/documenso:`) in production instead of `latest` to ensure predictable deployments. diff --git a/apps/docs/content/docs/self-hosting/deployment/manual.mdx b/apps/docs/content/docs/self-hosting/deployment/manual.mdx index bdd51fd68..d6dc4fda5 100644 --- a/apps/docs/content/docs/self-hosting/deployment/manual.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/manual.mdx @@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'; ## Prerequisites -- Node.js 20 or later -- npm +- Node.js 22 or later +- npm 11 or later - PostgreSQL 14 or later - A Linux server (for systemd service setup) diff --git a/apps/docs/content/docs/self-hosting/deployment/railway.mdx b/apps/docs/content/docs/self-hosting/deployment/railway.mdx index 81392a37d..501edca1c 100644 --- a/apps/docs/content/docs/self-hosting/deployment/railway.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/railway.mdx @@ -159,6 +159,12 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com | `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` | | `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` | | `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | | +| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` | +| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` | +| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` | +| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`| Hide the Microsoft signin button | `false` | +| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` | +| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` | | `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - | | `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` | diff --git a/apps/docs/content/docs/self-hosting/getting-started/quick-start.mdx b/apps/docs/content/docs/self-hosting/getting-started/quick-start.mdx index b0cc80ff8..9f913c766 100644 --- a/apps/docs/content/docs/self-hosting/getting-started/quick-start.mdx +++ b/apps/docs/content/docs/self-hosting/getting-started/quick-start.mdx @@ -124,12 +124,16 @@ docker compose -f docker/development/compose.yml exec database \ The quick start setup runs the following containers: -| Container | Purpose | Port | -| ----------- | -------------------------------- | ----- | -| `documenso` | Main application | 3000 | -| `database` | PostgreSQL database | 54320 | -| `maildev` | Local email testing server | 2500 | -| `minio` | S3-compatible storage (optional) | 9000 | +| Container | Purpose | Port | +| ----------- | ------------------------------------ | ----------------------------- | +| `documenso` | Main application | 3000 | +| `database` | PostgreSQL database | 54320 | +| `inbucket` | Local email testing server | 9000 (web UI), 2500 (SMTP) | +| `redis` | Cache and background job queue | 63790 | +| `minio` | S3-compatible storage | 9002 (API), 9001 (console) | +| `gotenberg` | Document conversion (optional) | 3005 | + +The local email server is [Inbucket](https://www.inbucket.org/). Open its web UI at [http://localhost:9000](http://localhost:9000) to view emails Documenso sends during development. For your own deployment you can use any SMTP-compatible mailserver, such as Inbucket, [Mailpit](https://github.com/axllent/mailpit), or [Mailhog](https://github.com/mailhog/MailHog). ## Useful Commands diff --git a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx index 13b2b75ab..c64bd081e 100644 --- a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx +++ b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx @@ -141,8 +141,8 @@ If building from source (not using Docker images): | Requirement | Version | | ----------- | ------- | -| Node.js | 18+ | -| npm | 8+ | +| Node.js | 22+ | +| npm | 11+ | --- @@ -169,7 +169,7 @@ Documenso runs on: | MySQL/MariaDB | PostgreSQL-specific features required | | SQLite | Not suitable for production workloads | | MongoDB | Relational database required | -| Node.js < 18 | Modern JavaScript features required | +| Node.js < 22 | Modern JavaScript features required | --- diff --git a/apps/docs/content/docs/self-hosting/getting-started/tips.mdx b/apps/docs/content/docs/self-hosting/getting-started/tips.mdx index 7fe640ab3..f4ff0e0a5 100644 --- a/apps/docs/content/docs/self-hosting/getting-started/tips.mdx +++ b/apps/docs/content/docs/self-hosting/getting-started/tips.mdx @@ -92,7 +92,7 @@ Use a specific version tag in production: ```bash # Good — predictable, reproducible -docker pull documenso/documenso:1.8.0 +docker pull documenso/documenso: # Risky — may pull breaking changes docker pull documenso/documenso:latest diff --git a/apps/docs/content/docs/self-hosting/index.mdx b/apps/docs/content/docs/self-hosting/index.mdx index 4263d30d4..99e4ecb01 100644 --- a/apps/docs/content/docs/self-hosting/index.mdx +++ b/apps/docs/content/docs/self-hosting/index.mdx @@ -27,6 +27,14 @@ import { Callout } from 'fumadocs-ui/components/callout'; Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding. + + **You are responsible for your own network security.** Documenso applies best-effort, non-exhaustive + checks to outbound requests such as webhooks, but these are not a complete SSRF mitigation and they + fail open. A self-hosted instance can reach internal addresses on your network. Restricting outbound + traffic, egress filtering, and blocking access to internal services and cloud metadata endpoints is + your responsibility through your firewall and network configuration. + + --- ## Deployment Options @@ -133,7 +141,7 @@ See the [Quick Start guide](/docs/self-hosting/getting-started/quick-start) for Self-hosted Documenso includes full core functionality under the AGPL-3.0 license. If you need enterprise features such as SSO, embed editor white label, or 21 CFR Part 11 compliance, you can activate them with a license key. -See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. +See [Enterprise Edition](/docs/policies/enterprise-edition) for details and [Licenses](/docs/policies/licenses) for a comparison. Already have a key? See [Apply Your License Key](/docs/self-hosting/configuration/license). --- diff --git a/apps/docs/content/docs/self-hosting/maintenance/upgrades.mdx b/apps/docs/content/docs/self-hosting/maintenance/upgrades.mdx index 4ac7879cf..832bb0088 100644 --- a/apps/docs/content/docs/self-hosting/maintenance/upgrades.mdx +++ b/apps/docs/content/docs/self-hosting/maintenance/upgrades.mdx @@ -165,10 +165,10 @@ See [Backups](/docs/self-hosting/maintenance/backups) for automated backup strat ### Pull the new image ```bash -docker pull documenso/documenso:1.6.0 +docker pull documenso/documenso: ``` -Replace `1.6.0` with your target version. +Replace `` with your target version. @@ -189,7 +189,7 @@ docker run -d \ -p 3000:3000 \ --env-file .env \ -v /path/to/cert.p12:/opt/documenso/cert.p12:ro \ - documenso/documenso:1.6.0 + documenso/documenso: ``` @@ -223,14 +223,14 @@ Edit `compose.yml` or your `.env` file to specify the new version: ```yaml services: documenso: - image: documenso/documenso:1.6.0 + image: documenso/documenso: ``` Or if using environment variable substitution: ```bash # In .env -DOCUMENSO_VERSION=1.6.0 +DOCUMENSO_VERSION= ``` ```yaml @@ -283,7 +283,7 @@ Edit the deployment directly: ```bash kubectl set image deployment/documenso \ - documenso=documenso/documenso:1.6.0 \ + documenso=documenso/documenso: \ -n documenso ``` @@ -295,7 +295,7 @@ spec: spec: containers: - name: documenso - image: documenso/documenso:1.6.0 + image: documenso/documenso: ``` Then apply: @@ -421,12 +421,12 @@ To run migrations manually before upgrading: ```bash # Pull the new image -docker pull documenso/documenso:1.6.0 +docker pull documenso/documenso: # Run migrations only docker run --rm \ -e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@host:5432/documenso" \ - documenso/documenso:1.6.0 \ + documenso/documenso: \ npx prisma migrate deploy ``` @@ -516,7 +516,7 @@ docker run -d \ -p 3000:3000 \ --env-file .env \ -v /path/to/cert.p12:/opt/documenso/cert.p12:ro \ - documenso/documenso:1.5.0 + documenso/documenso: ``` diff --git a/apps/docs/package.json b/apps/docs/package.json index aa45a5159..9f345d14c 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "build": "next build", - "dev": "next dev --port 3004", + "dev": "next dev", "start": "next start", "types:check": "fumadocs-mdx && next typegen && tsc --noEmit", "postinstall": "fumadocs-mdx" @@ -29,7 +29,7 @@ "@types/node": "^25.1.0", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", - "postcss": "^8.5.14", + "postcss": "^8.5.19", "tailwindcss": "^4.1.18", "typescript": "^5.9.3" } diff --git a/apps/docs/src/app/global.css b/apps/docs/src/app/global.css index 7d9c89316..ce71134c8 100644 --- a/apps/docs/src/app/global.css +++ b/apps/docs/src/app/global.css @@ -83,7 +83,7 @@ --accent: hsl(0 0% 27.8431%); --accent-foreground: hsl(95.0847 71.0843% 67.451%); --destructive: hsl(0 86.5979% 61.9608%); - --destructive-foreground: hsl(0 87.6289% 19.0196%); + --destructive-foreground: hsl(0 0% 98.0392%); --border: hsl(0 0% 27.8431%); --input: hsl(0 0% 27.8431%); --ring: hsl(95.0847 71.0843% 67.451%); diff --git a/apps/remix/Dockerfile b/apps/remix/Dockerfile deleted file mode 100644 index 207bf937e..000000000 --- a/apps/remix/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM node:20-alpine AS development-dependencies-env -COPY . /app -WORKDIR /app -RUN npm ci - -FROM node:20-alpine AS production-dependencies-env -COPY ./package.json package-lock.json /app/ -WORKDIR /app -RUN npm ci --omit=dev - -FROM node:20-alpine AS build-env -COPY . /app/ -COPY --from=development-dependencies-env /app/node_modules /app/node_modules -WORKDIR /app -RUN npm run build - -FROM node:20-alpine -COPY ./package.json package-lock.json /app/ -COPY --from=production-dependencies-env /app/node_modules /app/node_modules -COPY --from=build-env /app/build /app/build -WORKDIR /app -CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/apps/remix/README.md b/apps/remix/README.md index e0d20664e..6824c05b1 100644 --- a/apps/remix/README.md +++ b/apps/remix/README.md @@ -1,100 +1,14 @@ -# Welcome to React Router! +# @documenso/remix -A modern, production-ready template for building full-stack React applications using React Router. +The main Documenso web application. Built with [React Router v7](https://reactrouter.com/) and served by a [Hono](https://hono.dev/) server. -[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) +This package is part of the Documenso monorepo and is not meant to be run standalone. Use the root scripts instead. -## Features - -- 🚀 Server-side rendering -- ⚡️ Hot Module Replacement (HMR) -- 📦 Asset bundling and optimization -- 🔄 Data loading and mutations -- 🔒 TypeScript by default -- 🎉 TailwindCSS for styling -- 📖 [React Router docs](https://reactrouter.com/) - -## Getting Started - -### Installation - -Install the dependencies: - -```bash -npm install -``` - -### Development - -Start the development server with HMR: +- Local development: see the [root README](../../README.md) and the [Local Development docs](https://docs.documenso.com/docs/developers/local-development). +- Self-hosting and deployment: see the [Self-Hosting docs](https://docs.documenso.com/docs/self-hosting). +- Architecture overview: see [ARCHITECTURE.md](../../ARCHITECTURE.md). ```bash +# From the monorepo root npm run dev ``` - -Your application will be available at `http://localhost:5173`. - -## Building for Production - -Create a production build: - -```bash -npm run build -``` - -## Deployment - -### Docker Deployment - -This template includes three Dockerfiles optimized for different package managers: - -- `Dockerfile` - for npm -- `Dockerfile.pnpm` - for pnpm -- `Dockerfile.bun` - for bun - -To build and run using Docker: - -```bash -# For npm -docker build -t my-app . - -# For pnpm -docker build -f Dockerfile.pnpm -t my-app . - -# For bun -docker build -f Dockerfile.bun -t my-app . - -# Run the container -docker run -p 3000:3000 my-app -``` - -The containerized application can be deployed to any platform that supports Docker, including: - -- AWS ECS -- Google Cloud Run -- Azure Container Apps -- Digital Ocean App Platform -- Fly.io -- Railway - -### DIY Deployment - -If you're familiar with deploying Node applications, the built-in app server is production-ready. - -Make sure to deploy the output of `npm run build` - -``` -├── package.json -├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) -├── build/ -│ ├── client/ # Static assets -│ └── server/ # Server-side code -``` - -## Styling - -This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. - ---- - -Built with ❤️ using React Router. diff --git a/apps/remix/app/components/dialogs/branding-preferences-reset-dialog.tsx b/apps/remix/app/components/dialogs/branding-preferences-reset-dialog.tsx new file mode 100644 index 000000000..6205b1341 --- /dev/null +++ b/apps/remix/app/components/dialogs/branding-preferences-reset-dialog.tsx @@ -0,0 +1,119 @@ +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Trans } from '@lingui/react/macro'; +import { useState } from 'react'; + +export type BrandingPreferencesResetDialogProps = { + hasAdvancedBranding: boolean; + isSubmitting: boolean; + onReset: () => Promise; + trigger?: React.ReactNode; +}; + +export const BrandingPreferencesResetDialog = ({ + hasAdvancedBranding, + isSubmitting, + onReset, + trigger, +}: BrandingPreferencesResetDialogProps) => { + const [open, setOpen] = useState(false); + const [isResetting, setIsResetting] = useState(false); + + const isLoading = isSubmitting || isResetting; + + const handleResetToDefaults = async () => { + setIsResetting(true); + + try { + await onReset(); + setOpen(false); + } catch { + // The submit handler surfaces its own error toast. Keep the dialog open + // so the user can retry. + } finally { + setIsResetting(false); + } + }; + + return ( + !isLoading && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Reset branding preferences + + + + + This will reset all branding preferences to their default values and save the changes immediately. + + + + + + +

+ Once confirmed, the following will be reset: +

+ +
    +
  • + Custom branding enabled setting +
  • +
  • + Branding logo +
  • +
  • + Brand website and brand details +
  • +
  • + Brand colours, including background, foreground, primary, and border colours +
  • + + {hasAdvancedBranding && ( + <> +
  • + Border radius +
  • +
  • + Custom CSS +
  • + + )} +
+
+
+ + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx new file mode 100644 index 000000000..9adebb36e --- /dev/null +++ b/apps/remix/app/components/dialogs/document-preferences-reset-dialog.tsx @@ -0,0 +1,141 @@ +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Trans } from '@lingui/react/macro'; +import { useState } from 'react'; + +export type DocumentPreferencesResetDialogProps = { + isSubmitting: boolean; + onReset: () => Promise; + showAiFeatures?: boolean; + showDocumentVisibility?: boolean; + showIncludeSenderDetails?: boolean; +}; + +export const DocumentPreferencesResetDialog = ({ + isSubmitting, + onReset, + showAiFeatures = false, + showDocumentVisibility = false, + showIncludeSenderDetails = false, +}: DocumentPreferencesResetDialogProps) => { + const [open, setOpen] = useState(false); + const [isResetting, setIsResetting] = useState(false); + + const isLoading = isSubmitting || isResetting; + + const handleResetToDefaults = async () => { + setIsResetting(true); + + try { + await onReset(); + setOpen(false); + } catch { + // The submit handler surfaces its own error toast. Keep the dialog open + // so the user can retry. + } finally { + setIsResetting(false); + } + }; + + return ( + !isLoading && setOpen(value)}> + + + + + + + + Reset document preferences + + + + + This will reset all document preferences to their default values and save the changes immediately. + + + + + + +

+ Once confirmed, the following will be reset: +

+ +
    + {showDocumentVisibility && ( +
  • + Default document visibility +
  • + )} +
  • + Default document language +
  • +
  • + Default date format +
  • +
  • + Default time zone +
  • +
  • + Default signature settings +
  • + {showIncludeSenderDetails && ( +
  • + Send on behalf of team +
  • + )} +
  • + Include the signing certificate in the document +
  • +
  • + Include the audit logs in the document +
  • +
  • + Default recipients +
  • +
  • + Delegate document ownership +
  • +
  • + Default envelope expiration +
  • +
  • + Default signing reminders +
  • + {showAiFeatures && ( +
  • + AI features +
  • + )} +
+
+
+ + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/folder-create-dialog.tsx b/apps/remix/app/components/dialogs/folder-create-dialog.tsx index c2cf21eaa..395feaf37 100644 --- a/apps/remix/app/components/dialogs/folder-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-create-dialog.tsx @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -23,7 +24,7 @@ import { useParams } from 'react-router'; import { z } from 'zod'; const ZCreateFolderFormSchema = z.object({ - name: z.string().min(1, { message: 'Folder name is required' }), + name: ZNameSchema, }); type TCreateFolderFormSchema = z.infer; @@ -65,7 +66,7 @@ export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }: toast({ description: t`Folder created successfully`, }); - } catch (err) { + } catch (_err) { toast({ title: t`Failed to create folder`, description: t`An unknown error occurred while creating the folder.`, diff --git a/apps/remix/app/components/dialogs/folder-delete-dialog.tsx b/apps/remix/app/components/dialogs/folder-delete-dialog.tsx index e05f943b3..aca0e7e31 100644 --- a/apps/remix/app/components/dialogs/folder-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-delete-dialog.tsx @@ -122,7 +122,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet Confirm by typing:{' '} - {deleteMessage} + {deleteMessage} diff --git a/apps/remix/app/components/dialogs/folder-update-dialog.tsx b/apps/remix/app/components/dialogs/folder-update-dialog.tsx index 1c57bc27d..db859431a 100644 --- a/apps/remix/app/components/dialogs/folder-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/folder-update-dialog.tsx @@ -1,5 +1,6 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { Button } from '@documenso/ui/primitives/button'; @@ -23,8 +24,6 @@ import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { useOptionalCurrentTeam } from '~/providers/team'; - export type FolderUpdateDialogProps = { folder: TFolderWithSubfolders | null; isOpen: boolean; @@ -32,7 +31,7 @@ export type FolderUpdateDialogProps = { } & Omit; export const ZUpdateFolderFormSchema = z.object({ - name: z.string().min(1), + name: ZNameSchema, visibility: z.nativeEnum(DocumentVisibility).optional(), }); @@ -40,7 +39,6 @@ export type TUpdateFolderFormSchema = z.infer; export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdateDialogProps) => { const { t } = useLingui(); - const team = useOptionalCurrentTeam(); const { toast } = useToast(); const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation(); diff --git a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx index 183dfbeec..c76bc00a2 100644 --- a/apps/remix/app/components/dialogs/organisation-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -336,7 +336,7 @@ const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }: >
-

+

Free

diff --git a/apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx b/apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx index c903cd079..a0308e530 100644 --- a/apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-email-domain-delete-dialog.tsx @@ -115,7 +115,7 @@ export const OrganisationEmailDomainDeleteDialog = ({ Confirm by typing{' '} - {deleteMessage} + {deleteMessage} diff --git a/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx index a033e7bff..d68c0f5d3 100644 --- a/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx +++ b/apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx @@ -370,7 +370,7 @@ export const OrganisationMemberInviteDialog = ({ trigger, ...props }: Organisati + )} + + + { + // Prevent losing the created token by accidentally clicking outside the dialog. + if (createdToken) { + event.preventDefault(); + } + }} + > + {createdToken ? ( + <> + + + Token created + + + + Copy your token now. For security reasons you will not be able to see it again. + + + +
+ +
+ toast({ title: _(msg`Token copied to clipboard`) })} + /> +
+
+ + + + + + ) : ( + <> + + + Create API token + + + + Use API tokens to authenticate with the Documenso API. + + + +
+ +
+ ( + + + Name + + + + + + + + A name to help you identify this token later. + + + + + )} + /> + + ( + + + Expires in + + + + + + + + + )} + /> + + + + + + +
+
+ + + )} +
+ + ); +}; diff --git a/apps/remix/app/components/dialogs/token-delete-dialog.tsx b/apps/remix/app/components/dialogs/token-delete-dialog.tsx index 63f1465b4..0e3553a4d 100644 --- a/apps/remix/app/components/dialogs/token-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/token-delete-dialog.tsx @@ -105,7 +105,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe - Are you sure you want to delete this token? + Delete token @@ -126,7 +126,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe Confirm by typing:{' '} - {deleteMessage} + {deleteMessage} @@ -139,21 +139,18 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe /> -
- + - -
+
diff --git a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx index d2f8bc059..3401a2083 100644 --- a/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx @@ -117,7 +117,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr Confirm by typing:{' '} - {deleteMessage} + {deleteMessage} diff --git a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx index 4a3a7036f..19a2d4b92 100644 --- a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx +++ b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -503,7 +503,7 @@ export const ConfigureFieldsView = ({ {selectedField && (
file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB') - .refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted') + .refine( + (file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES, + `File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`, + ) + .refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted') .nullish(), brandingUrl: z.string().url().optional().or(z.literal('')), brandingCompanyDetails: z.string().max(500).optional(), @@ -67,42 +76,123 @@ export function BrandingPreferencesForm({ const [previewUrl, setPreviewUrl] = useState(''); const [hasLoadedPreview, setHasLoadedPreview] = useState(false); + const [colorPickerKey, setColorPickerKey] = useState(0); const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors); const initialColors = parsedColors.success ? parsedColors.data : {}; + // The saved state the form maps to. Used both as the reactive `values` source and as + // the explicit target for a Reset (see handleReset). + const savedValues: TBrandingPreferencesFormSchema = { + brandingEnabled: settings.brandingEnabled ?? null, + brandingUrl: settings.brandingUrl ?? '', + brandingLogo: undefined, + brandingCompanyDetails: settings.brandingCompanyDetails ?? '', + brandingColors: initialColors, + brandingCss: settings.brandingCss ?? '', + }; + const form = useForm({ - values: { - brandingEnabled: settings.brandingEnabled ?? null, - brandingUrl: settings.brandingUrl ?? '', - brandingLogo: undefined, - brandingCompanyDetails: settings.brandingCompanyDetails ?? '', - brandingColors: initialColors, - brandingCss: settings.brandingCss ?? '', - }, + values: savedValues, resolver: zodResolver(ZBrandingPreferencesFormSchema), }); const isBrandingEnabled = form.watch('brandingEnabled'); + const hasResetBrandingColors = + settings.brandingColors === null || + settings.brandingColors === undefined || + (parsedColors.success && normalizeBrandingColors(parsedColors.data) === null); + + // Only show the reset action when the saved settings actually differ from the + // defaults, so it never renders as a pointless disabled button. + const isResetToDefaultsVisible = + settings.brandingEnabled !== (canInherit ? null : false) || + !!settings.brandingLogo || + !!settings.brandingUrl || + !!settings.brandingCompanyDetails || + !!settings.brandingCss || + !hasResetBrandingColors; + + const handleResetToDefaults = async () => { + const data: TBrandingPreferencesFormSchema = { + brandingEnabled: canInherit ? null : false, + brandingLogo: null, + brandingUrl: '', + brandingCompanyDetails: '', + brandingColors: {}, + brandingCss: '', + }; + + await onFormSubmit(data); + + if (previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + + setPreviewUrl(''); + setColorPickerKey((key) => key + 1); + form.reset(data); + }; + + const getSavedLogoPreviewUrl = () => { + if (!settings.brandingLogo) { + return ''; + } + + const file = JSON.parse(settings.brandingLogo); + + if (!('type' in file) || !('data' in file)) { + return ''; + } + + const logoUrl = + context === 'Team' + ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}` + : `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`; + + return `${logoUrl}?v=${Date.now()}`; + }; + useEffect(() => { - if (settings.brandingLogo) { - const file = JSON.parse(settings.brandingLogo); + const savedLogoPreviewUrl = getSavedLogoPreviewUrl(); - if ('type' in file && 'data' in file) { - const logoUrl = - context === 'Team' - ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}` - : `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`; - - setPreviewUrl(logoUrl + '?v=' + Date.now()); - setHasLoadedPreview(true); - } + if (savedLogoPreviewUrl) { + setPreviewUrl(savedLogoPreviewUrl); } setHasLoadedPreview(true); }, [settings.brandingLogo]); + // Reset the form to the saved values. The form is driven by the `values` prop (no + // `defaultValues`), so `reset()` with no argument doesn't re-baseline the dirty check; + // passing the saved values clears the per-field dirty tracking (dirtyFields). + const handleReset = () => { + setPreviewUrl(getSavedLogoPreviewUrl()); + form.reset(savedValues); + }; + + // `formState.isDirty` is unreliable for a `values`-driven form: after a reset (or a + // save + refetch) it can stay true even though every field already matches its saved + // value and `dirtyFields` is empty. Derive the flag from `dirtyFields` instead so the + // sticky save bar reliably disappears. + const hasUnsavedChanges = Object.keys(form.formState.dirtyFields).length > 0; + + // Re-baseline the form to the just-saved state after a successful submit. The `values` + // prop re-syncs most fields once the route refetches, but write-only fields (the logo + // is a File that isn't reflected back into `values`) would otherwise stay dirty and + // keep the save bar visible. Relies on the page handler rethrowing on error so we only + // re-baseline on success. + const handleFormSubmit = form.handleSubmit(async (data) => { + try { + await onFormSubmit(data); + } catch { + return; + } + + form.reset(form.getValues()); + }); + // Cleanup ObjectURL on unmount or when previewUrl changes useEffect(() => { return () => { @@ -114,7 +204,7 @@ export function BrandingPreferencesForm({ return (
- +
- {!isBrandingEnabled &&
} + {!isBrandingEnabled &&
} { const file = e.target.files?.[0]; @@ -321,7 +411,7 @@ export function BrandingPreferencesForm({ {hasAdvancedBranding && (
- {!isBrandingEnabled &&
} + {!isBrandingEnabled &&
}
@@ -346,6 +436,7 @@ export function BrandingPreferencesForm({ )} -
- -
+ + ) : undefined + } + />
diff --git a/apps/remix/app/components/forms/document-preferences-form.tsx b/apps/remix/app/components/forms/document-preferences-form.tsx index ee552d043..cdf39eb51 100644 --- a/apps/remix/app/components/forms/document-preferences-form.tsx +++ b/apps/remix/app/components/forms/document-preferences-form.tsx @@ -11,17 +11,16 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr import { TIME_ZONES } from '@documenso/lib/constants/time-zones'; import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients'; import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients'; -import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta'; -import { isPersonalLayout } from '@documenso/lib/utils/organisations'; +import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta'; +import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams'; +import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams'; import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip'; import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker'; import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker'; import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select'; import { Alert } from '@documenso/ui/primitives/alert'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; import { Combobox } from '@documenso/ui/primitives/combobox'; import { Form, @@ -38,14 +37,15 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg, t } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { TeamGlobalSettings } from '@prisma/client'; -import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client'; +import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog'; import { useOptionalCurrentTeam } from '~/providers/team'; import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox'; +import { FormStickySaveBar } from './form-sticky-save-bar'; /** * Can't infer this from the schema since we need to keep the schema inside the component to allow @@ -93,6 +93,26 @@ export type DocumentPreferencesFormProps = { onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise; }; +const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => { + const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat); + + return { + documentVisibility: settings.documentVisibility, + documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null, + documentTimezone: settings.documentTimezone, + documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null, + includeSenderDetails: settings.includeSenderDetails, + includeSigningCertificate: settings.includeSigningCertificate, + includeAuditLog: settings.includeAuditLog, + signatureTypes: extractTeamSignatureSettings({ ...settings }), + defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null, + delegateDocumentOwnership: settings.delegateDocumentOwnership, + aiFeaturesEnabled: settings.aiFeaturesEnabled, + envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null, + reminderSettings: settings.reminderSettings ?? null, + }; +}; + export const DocumentPreferencesForm = ({ settings, onFormSubmit, @@ -113,7 +133,7 @@ export const DocumentPreferencesForm = ({ documentVisibility: z.nativeEnum(DocumentVisibility).nullable(), documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(), documentTimezone: z.string().nullable(), - documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(), + documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(), includeSenderDetails: z.boolean().nullable(), includeSigningCertificate: z.boolean().nullable(), includeAuditLog: z.boolean().nullable(), @@ -127,29 +147,48 @@ export const DocumentPreferencesForm = ({ reminderSettings: ZEnvelopeReminderSettings.nullable(), }); + const defaultValues = getDocumentPreferencesFormValues(settings); + const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings(); + const baseResetValues = getDocumentPreferencesFormValues(defaultSettings); + const resetValues = { + ...baseResetValues, + aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled, + }; + const form = useForm({ - defaultValues: { - documentVisibility: settings.documentVisibility, - documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null, - documentTimezone: settings.documentTimezone, - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null, - includeSenderDetails: settings.includeSenderDetails, - includeSigningCertificate: settings.includeSigningCertificate, - includeAuditLog: settings.includeAuditLog, - signatureTypes: extractTeamSignatureSettings({ ...settings }), - defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null, - delegateDocumentOwnership: settings.delegateDocumentOwnership, - aiFeaturesEnabled: settings.aiFeaturesEnabled, - envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null, - reminderSettings: settings.reminderSettings ?? null, - }, + defaultValues, resolver: zodResolver(ZDocumentPreferencesFormSchema), }); + // Parse both sides through the schema so we compare canonical representations + const parsedCurrentValues = ZDocumentPreferencesFormSchema.safeParse(defaultValues); + const parsedResetValues = ZDocumentPreferencesFormSchema.safeParse(resetValues); + + const isResetToDefaultsVisible = + !parsedCurrentValues.success || + !parsedResetValues.success || + JSON.stringify(parsedCurrentValues.data) !== JSON.stringify(parsedResetValues.data); + + const handleResetToDefaults = async () => { + await onFormSubmit(resetValues); + form.reset(resetValues); + }; + + const handleFormSubmit = form.handleSubmit(async (data) => { + try { + await onFormSubmit(data); + } catch { + // The page handler surfaces its own error toast. Keep the form dirty so + // the save bar stays visible and the user can retry. + return; + } + + form.reset(data); + }); + return (
- +
{!isPersonalLayoutMode && ( )} -
- -
+ form.reset()} + resetToDefaults={ + isResetToDefaultsVisible ? ( + + ) : undefined + } + />
diff --git a/apps/remix/app/components/forms/email-preferences-form.tsx b/apps/remix/app/components/forms/email-preferences-form.tsx index 4ab981d93..818868005 100644 --- a/apps/remix/app/components/forms/email-preferences-form.tsx +++ b/apps/remix/app/components/forms/email-preferences-form.tsx @@ -4,7 +4,6 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@ import { zEmail } from '@documenso/lib/utils/zod'; import { trpc } from '@documenso/trpc/react'; import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes'; -import { Button } from '@documenso/ui/primitives/button'; import { Form, FormControl, @@ -22,6 +21,8 @@ import type { TeamGlobalSettings } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + const ZEmailPreferencesFormSchema = z.object({ emailId: z.string().nullable(), emailReplyTo: zEmail().nullable(), @@ -59,9 +60,21 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema const emails = emailData?.data || []; + const handleFormSubmit = form.handleSubmit(async (data) => { + try { + await onFormSubmit(data); + } catch { + // The page handler surfaces its own error toast. Keep the form dirty so + // the save bar stays visible and the user can retry. + return; + } + + form.reset(data); + }); + return (
- +
{organisation.organisationClaim.flags.emailDomains && ( -
- -
+ form.reset()} + />
diff --git a/apps/remix/app/components/forms/email-transport-form.tsx b/apps/remix/app/components/forms/email-transport-form.tsx index 2e20fdb59..c8af2f478 100644 --- a/apps/remix/app/components/forms/email-transport-form.tsx +++ b/apps/remix/app/components/forms/email-transport-form.tsx @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { Form, FormControl, @@ -15,8 +16,8 @@ import { useForm } from 'react-hook-form'; import { z } from 'zod'; const ZEmailTransportFormSchema = z.object({ - name: z.string().min(1), - fromName: z.string().min(1), + name: ZNameSchema, + fromName: ZNameSchema, fromAddress: z.string().email(), type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']), host: z.string().optional(), diff --git a/apps/remix/app/components/forms/form-sticky-save-bar.tsx b/apps/remix/app/components/forms/form-sticky-save-bar.tsx new file mode 100644 index 000000000..addc155e7 --- /dev/null +++ b/apps/remix/app/components/forms/form-sticky-save-bar.tsx @@ -0,0 +1,126 @@ +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { AnimatePresence, motion } from 'framer-motion'; +import { AlertTriangleIcon } from 'lucide-react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; + +export type FormStickySaveBarProps = { + isDirty: boolean; + isSubmitting: boolean; + onReset: () => void; + /** + * Slot for a "reset to defaults" action, rendered before the Undo button. Hidden while + * the bar is floating so it never appears in the unsaved-changes island. + */ + resetToDefaults?: ReactNode; +}; + +/** + * A single `position: sticky` bar rendered at the bottom of the form. + * + * - When the form's end is on screen it settles into place as a plain footer (just the + * Reset / Save buttons). + * - When the form's end is scrolled off, it sticks to the bottom of the viewport and + * shows the "unsaved changes" pill chrome. + * + * Because it's the same element in the form's flow, it auto-aligns to the form and the + * float <-> dock hand-off is a native, scroll-linked transition (no measurement, no + * shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle + * the pill chrome. + */ +export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefaults }: FormStickySaveBarProps) => { + const { t } = useLingui(); + + const sentinelRef = useRef(null); + const [isStuck, setIsStuck] = useState(false); + + useEffect(() => { + const sentinel = sentinelRef.current; + + if (!sentinel) { + return; + } + + // The sentinel sits at the bar's resting position (the end of the form). While the + // bar is stuck to the bottom of the viewport the sentinel is scrolled past (out of + // view); once you reach the form's end it comes into view and the bar settles. + const observer = new IntersectionObserver( + ([entry]) => { + setIsStuck(!entry.isIntersecting); + }, + { + root: null, + rootMargin: '0px 0px -24px 0px', + threshold: 0, + }, + ); + + observer.observe(sentinel); + + return () => { + observer.disconnect(); + }; + }, []); + + // Show the floating pill chrome only when there are unsaved changes AND the form's + // end is off screen. + const isFloating = isDirty && isStuck; + + return ( + <> +
+ + {isFloating && ( + + + + You have unsaved changes + + + )} + + +
+ {!isFloating && resetToDefaults} + + {isDirty && ( + + )} + + +
+
+ + {/* Sentinel: detects when the sticky bar is floating (stuck) vs settled (docked). */} +
+ + ); +}; diff --git a/apps/remix/app/components/forms/organisation-update-form.tsx b/apps/remix/app/components/forms/organisation-update-form.tsx index 7ac15b1c7..5d4c2c88f 100644 --- a/apps/remix/app/components/forms/organisation-update-form.tsx +++ b/apps/remix/app/components/forms/organisation-update-form.tsx @@ -4,7 +4,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types'; -import { Button } from '@documenso/ui/primitives/button'; 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'; @@ -12,11 +11,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import type { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({ name: true, url: true, @@ -137,36 +137,11 @@ export const OrganisationUpdateForm = () => { )} /> -
- - {form.formState.isDirty && ( - - - - )} - - - -
+ form.reset()} + /> diff --git a/apps/remix/app/components/forms/profile.tsx b/apps/remix/app/components/forms/profile.tsx index a9b81bc02..3449a747f 100644 --- a/apps/remix/app/components/forms/profile.tsx +++ b/apps/remix/app/components/forms/profile.tsx @@ -1,5 +1,5 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; -import { ZNameSchema } from '@documenso/lib/constants/auth'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; diff --git a/apps/remix/app/components/forms/signin.tsx b/apps/remix/app/components/forms/signin.tsx index 09e72d7b2..16b9943ca 100644 --- a/apps/remix/app/components/forms/signin.tsx +++ b/apps/remix/app/components/forms/signin.tsx @@ -58,6 +58,7 @@ export type TSignInFormSchema = z.infer; export type SignInFormProps = { className?: string; initialEmail?: string; + isEmailPasswordSigninEnabled?: boolean; isGoogleSSOEnabled?: boolean; isMicrosoftSSOEnabled?: boolean; isOIDCSSOEnabled?: boolean; @@ -68,6 +69,7 @@ export type SignInFormProps = { export const SignInForm = ({ className, initialEmail, + isEmailPasswordSigninEnabled = true, isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, @@ -324,66 +326,78 @@ export const SignInForm = ({
- ( - - - Email - + {isEmailPasswordSigninEnabled && ( + <> + ( + + + Email + - - - + + + - - - )} - /> + + + )} + /> - ( - - - Password - + ( + + + Password + - - - + + + - + -

- - Forgot your password? - -

-
- )} - /> +

+ + Forgot your password? + +

+
+ )} + /> - {turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && ( - + {turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && ( + + )} + + + )} - - {!isEmbeddedRedirect && ( <> - {hasSocialAuthEnabled && ( + {isEmailPasswordSigninEnabled && hasSocialAuthEnabled && (
diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index a53131f08..ea22b2789 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -1,8 +1,8 @@ import communityCardsImage from '@documenso/assets/images/community-cards.png'; import { authClient } from '@documenso/auth/client'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; -import { ZNameSchema } from '@documenso/lib/constants/auth'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { env } from '@documenso/lib/utils/env'; import { zEmail } from '@documenso/lib/utils/zod'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; @@ -96,7 +96,7 @@ export const SignUpForm = ({ password: '', signature: '', }, - mode: 'onBlur', + mode: 'onChange', resolver: zodResolver(ZSignUpFormSchema), }); diff --git a/apps/remix/app/components/forms/team-update-form.tsx b/apps/remix/app/components/forms/team-update-form.tsx index fdf569f95..8dddaee2b 100644 --- a/apps/remix/app/components/forms/team-update-form.tsx +++ b/apps/remix/app/components/forms/team-update-form.tsx @@ -2,7 +2,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { trpc } from '@documenso/trpc/react'; import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types'; -import { Button } from '@documenso/ui/primitives/button'; 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'; @@ -10,11 +9,12 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import type { z } from 'zod'; +import { FormStickySaveBar } from './form-sticky-save-bar'; + export type UpdateTeamDialogProps = { teamId: number; teamName: string; @@ -135,36 +135,11 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr )} /> -
- - {form.formState.isDirty && ( - - - - )} - - - -
+ form.reset()} + />
diff --git a/apps/remix/app/components/forms/token.tsx b/apps/remix/app/components/forms/token.tsx deleted file mode 100644 index 4239b0c76..000000000 --- a/apps/remix/app/components/forms/token.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; -import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { trpc } from '@documenso/trpc/react'; -import { ZCreateApiTokenRequestSchema } from '@documenso/trpc/server/api-token-router/create-api-token.types'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; -import { Input } from '@documenso/ui/primitives/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; -import { Switch } from '@documenso/ui/primitives/switch'; -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 type { ApiToken } from '@prisma/client'; -import { AnimatePresence, motion } from 'framer-motion'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { match } from 'ts-pattern'; -import type { z } from 'zod'; - -import { useCurrentTeam } from '~/providers/team'; - -export const EXPIRATION_DATES = { - ONE_WEEK: msg`7 days`, - ONE_MONTH: msg`1 month`, - THREE_MONTHS: msg`3 months`, - SIX_MONTHS: msg`6 months`, - ONE_YEAR: msg`12 months`, -} as const; - -const ZCreateTokenFormSchema = ZCreateApiTokenRequestSchema.pick({ - tokenName: true, - expirationDate: true, -}); - -type TCreateTokenFormSchema = z.infer; - -type NewlyCreatedToken = { - id: number; - token: string; -}; - -export type ApiTokenFormProps = { - className?: string; - tokens?: Pick[]; -}; - -export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => { - const [, copy] = useCopyToClipboard(); - - const team = useCurrentTeam(); - - const { _ } = useLingui(); - const { toast } = useToast(); - - const [newlyCreatedToken, setNewlyCreatedToken] = useState(); - const [noExpirationDate, setNoExpirationDate] = useState(false); - - const { mutateAsync: createTokenMutation } = trpc.apiToken.create.useMutation({ - onSuccess(data) { - setNewlyCreatedToken(data); - }, - }); - - const form = useForm({ - resolver: zodResolver(ZCreateTokenFormSchema), - defaultValues: { - tokenName: '', - expirationDate: '', - }, - }); - - const copyToken = async (token: string) => { - try { - const copied = await copy(token); - - if (!copied) { - throw new Error('Unable to copy the token'); - } - - toast({ - title: _(msg`Token copied to clipboard`), - description: _(msg`The token was copied to your clipboard.`), - }); - } catch (error) { - toast({ - title: _(msg`Unable to copy token`), - description: _(msg`We were unable to copy the token to your clipboard. Please try again.`), - variant: 'destructive', - }); - } - }; - - const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenFormSchema) => { - try { - await createTokenMutation({ - teamId: team.id, - tokenName, - expirationDate: noExpirationDate ? null : expirationDate, - }); - - toast({ - title: _(msg`Token created`), - description: _(msg`A new token was created successfully.`), - duration: 5000, - }); - - form.reset(); - } catch (err) { - const error = AppError.parseError(err); - - const errorMessage = match(error.code) - .with(AppErrorCode.UNAUTHORIZED, () => msg`You do not have permission to create a token for this team.`) - .otherwise(() => msg`Something went wrong. Please try again later.`); - - toast({ - title: _(msg`An error occurred`), - description: _(errorMessage), - variant: 'destructive', - duration: 5000, - }); - } - }; - - return ( -
-
- -
- ( - - - Token name - - -
- - - -
- - - Please enter a meaningful name for your token. This will help you identify it later. - - - -
- )} - /> - -
- ( - - - Token expiration date - - -
- - - -
- - -
- )} - /> - -
- - Never expire - -
- -
-
-
- - - -
- -
-
-
- - - - {newlyCreatedToken && tokens && tokens.find((token) => token.id === newlyCreatedToken.id) && ( - - - -

- - Your token was created successfully! Make sure to copy it because you won't be able to see it again! - -

- -

- {newlyCreatedToken.token} -

- - -
-
-
- )} -
-
- ); -}; diff --git a/apps/remix/app/components/general/admin-global-settings-section.tsx b/apps/remix/app/components/general/admin-global-settings-section.tsx index 5f21e7900..760684077 100644 --- a/apps/remix/app/components/general/admin-global-settings-section.tsx +++ b/apps/remix/app/components/general/admin-global-settings-section.tsx @@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client'; +import type { ReactNode } from 'react'; import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; @@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument type AdminGlobalSettingsSectionProps = { settings: TeamGlobalSettings | OrganisationGlobalSettings | null; isTeam?: boolean; + /** When viewing a team, the parent organisation settings the team inherits from. */ + inheritedSettings?: OrganisationGlobalSettings | null; }; -export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => { +export const AdminGlobalSettingsSection = ({ + settings, + isTeam = false, + inheritedSettings, +}: AdminGlobalSettingsSectionProps) => { const { _ } = useLingui(); - const notSetLabel = isTeam ? Inherited : Not set; if (!settings) { return null; } - const textValue = (value: string | null | undefined) => { - if (value === null || value === undefined) { - return notSetLabel; + const notSet = Not set; + + const inheritedValue = (value: ReactNode) => { + if (!isTeam || value === null) { + return notSet; } - return value; + return ( + + + Inherited: + + {value} + + ); }; - const brandingTextValue = (value: string | null | undefined) => { - if (value === null || value === undefined || value.trim() === '') { - return notSetLabel; + const textValue = (value: string | null | undefined, inherited?: string | null) => { + if (value && value.trim() !== '') { + return value; } - return value; + if (inherited && inherited.trim() !== '') { + return inheritedValue(inherited); + } + + return notSet; }; - const booleanValue = (value: boolean | null | undefined) => { - if (value === null || value === undefined) { - return notSetLabel; + const booleanLabel = (value: boolean) => (value ? Enabled : Disabled); + + const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => { + if (value !== null && value !== undefined) { + return booleanLabel(value); } - return value ? Enabled : Disabled; + return inherited !== null && inherited !== undefined ? inheritedValue(booleanLabel(inherited)) : notSet; + }; + + const visibilityLabel = (value: string | null | undefined) => { + return value && DOCUMENT_VISIBILITY[value] ? _(DOCUMENT_VISIBILITY[value].value) : null; + }; + + const visibilityValue = (value: string | null | undefined, inherited?: string | null) => { + const label = visibilityLabel(value); + + if (label !== null) { + return label; + } + + return inheritedValue(visibilityLabel(inherited)); }; const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings); @@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
Document visibility}> - {settings.documentVisibility != null - ? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value) - : notSetLabel} + {visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)} Document language}> - {textValue(settings.documentLanguage)} + {textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)} Document timezone}> - {textValue(settings.documentTimezone)} + {textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)} Date format}> - {textValue(settings.documentDateFormat)} + {textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)} Include sender details}> - {booleanValue(settings.includeSenderDetails)} + + {booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)} + Include signing certificate}> - {booleanValue(settings.includeSigningCertificate)} + + {booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)} + Include audit log}> - {booleanValue(settings.includeAuditLog)} + {booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)} Delegate document ownership}> - {booleanValue(settings.delegateDocumentOwnership)} + + {booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)} + Typed signature}> - {booleanValue(settings.typedSignatureEnabled)} + + {booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)} + Upload signature}> - {booleanValue(settings.uploadSignatureEnabled)} + + {booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)} + Draw signature}> - {booleanValue(settings.drawSignatureEnabled)} + + {booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)} + Branding}> - {booleanValue(settings.brandingEnabled)} + {booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)} Branding logo}> - {brandingTextValue(settings.brandingLogo)} + {textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)} Branding URL}> - {brandingTextValue(settings.brandingUrl)} + {textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)} Branding company details}> - {brandingTextValue(settings.brandingCompanyDetails)} + + {textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)} + Email reply-to}> - {textValue(settings.emailReplyTo)} + {textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)} {isTeam && parsedEmailSettings.success && ( @@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl )} AI features}> - {booleanValue(settings.aiFeaturesEnabled)} + {booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}
); diff --git a/apps/remix/app/components/general/admin-license-card.tsx b/apps/remix/app/components/general/admin-license-card.tsx index a5ed1fd90..c3a3428df 100644 --- a/apps/remix/app/components/general/admin-license-card.tsx +++ b/apps/remix/app/components/general/admin-license-card.tsx @@ -87,7 +87,7 @@ export const AdminLicenseCard = ({ licenseData }: AdminLicenseCardProps) => {
-

+

Documenso License

diff --git a/apps/remix/app/components/general/claim-account.tsx b/apps/remix/app/components/general/claim-account.tsx index 110549bc7..4301391e6 100644 --- a/apps/remix/app/components/general/claim-account.tsx +++ b/apps/remix/app/components/general/claim-account.tsx @@ -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 { ZNameSchema } from '@documenso/lib/types/name'; import { env } from '@documenso/lib/utils/env'; import { zEmail } from '@documenso/lib/utils/zod'; import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; @@ -19,7 +20,6 @@ import { useRef } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { z } from 'zod'; - import { SIGNUP_ERROR_MESSAGES } from '~/components/forms/signup'; export type ClaimAccountProps = { @@ -30,7 +30,7 @@ export type ClaimAccountProps = { export const ZClaimAccountFormSchema = z .object({ - name: z.string().trim().min(1, { message: msg`Please enter a valid name.`.id }), + name: ZNameSchema, email: zEmail().min(1), password: ZPasswordSchema, }) diff --git a/apps/remix/app/components/general/claim-limit-fields.tsx b/apps/remix/app/components/general/claim-limit-fields.tsx index ed29c8fc3..c90a92430 100644 --- a/apps/remix/app/components/general/claim-limit-fields.tsx +++ b/apps/remix/app/components/general/claim-limit-fields.tsx @@ -1,11 +1,4 @@ -import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@documenso/ui/primitives/form/form'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; import { Trans, useLingui } from '@lingui/react/macro'; import type { ReactNode } from 'react'; @@ -13,6 +6,13 @@ import type { Control, FieldValues, Path } from 'react-hook-form'; import { RateLimitArrayInput } from './rate-limit-array-input'; +/** + * The rate-limit editor renders its own per-row inline errors, but a submit + * attempt can still surface array-level Zod issues (e.g. a committed duplicate + * window). Rendering the field's message here guarantees the form never fails + * silently when those errors are not tied to a row the editor is showing. + */ + type ClaimLimitFieldsProps = { control: Control; /** e.g. '' for the claim form, 'claims.' for the org admin form. */ @@ -20,6 +20,12 @@ type ClaimLimitFieldsProps = { disabled?: boolean; }; +type LimitGroup = { + title: ReactNode; + quotaKey: string; + rateLimitKey: string; +}; + export const ClaimLimitFields = ({ control, prefix = '', @@ -30,13 +36,33 @@ export const ClaimLimitFields = ({ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const name = (key: string) => `${prefix}${key}` as Path; - const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => ( + const limitGroups: LimitGroup[] = [ + { + title: Documents, + quotaKey: 'documentQuota', + rateLimitKey: 'documentRateLimits', + }, + { + title: Emails, + quotaKey: 'emailQuota', + rateLimitKey: 'emailRateLimits', + }, + { + title: API, + quotaKey: 'apiQuota', + rateLimitKey: 'apiRateLimits', + }, + ]; + + const renderQuotaField = (group: LimitGroup) => ( ( - {label} + + Monthly quota + ({ onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))} /> - {description} )} /> ); - const renderRateLimitField = (key: string, label: ReactNode) => ( + const renderRateLimitField = (group: LimitGroup) => ( ( - {label} @@ -71,27 +95,30 @@ export const ClaimLimitFields = ({ ); return ( -
- - Limits - +
+
+

+ Limits +

+

+ + Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h. + +

+
- {renderQuotaField( - 'documentQuota', - Monthly document quota, - Empty = Unlimited, 0 = Blocked, - )} - {renderRateLimitField('documentRateLimits', Document rate limits)} +
+
+ {limitGroups.map((group) => ( +
+

{group.title}

- {renderQuotaField( - 'emailQuota', - Monthly email quota, - Empty = Unlimited, 0 = Blocked, - )} - {renderRateLimitField('emailRateLimits', Email rate limits)} - - {renderQuotaField('apiQuota', Monthly API quota, Empty = Unlimited, 0 = Blocked)} - {renderRateLimitField('apiRateLimits', API rate limits)} + {renderQuotaField(group)} + {renderRateLimitField(group)} +
+ ))} +
+
); }; diff --git a/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx new file mode 100644 index 000000000..c95b4109f --- /dev/null +++ b/apps/remix/app/components/general/direct-template/direct-template-invalid-page.tsx @@ -0,0 +1,23 @@ +import { Trans } from '@lingui/react/macro'; +import { AlertTriangleIcon } from 'lucide-react'; + +export const DirectTemplateInvalidPageView = () => { + return ( +
+
+ + +

+ Invalid direct link template +

+ +

+ + This direct link template cannot be used because one or more signers do not have a signature field assigned. + Please contact the sender to update the template. + +

+
+
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx b/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx index e472bb5c9..0ab921a59 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx @@ -1,3 +1,4 @@ +import { toSafeHref } from '@documenso/lib/utils/is-http-url'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; @@ -53,7 +54,7 @@ export const DocumentSigningAttachmentsPopover = ({ {attachments?.data.map((attachment) => ( ; @@ -156,7 +157,7 @@ export const DocumentAttachmentsPopover = ({

{attachment.label}

{ +export const DocumentAuditLogDownloadButton = ({ className, envelopeId }: DocumentAuditLogDownloadButtonProps) => { const { toast } = useToast(); const { _ } = useLingui(); @@ -22,7 +22,7 @@ export const DocumentAuditLogDownloadButton = ({ className, documentId }: Docume const onDownloadAuditLogsClick = async () => { try { - const { data, envelopeTitle } = await downloadAuditLogs({ documentId }); + const { data, envelopeTitle } = await downloadAuditLogs({ envelopeId }); const buffer = new Uint8Array(base64.decode(data)); const blob = new Blob([buffer], { type: 'application/pdf' }); diff --git a/apps/remix/app/components/general/document/document-certificate-download-button.tsx b/apps/remix/app/components/general/document/document-certificate-download-button.tsx index a862ada7f..f75b41dd9 100644 --- a/apps/remix/app/components/general/document/document-certificate-download-button.tsx +++ b/apps/remix/app/components/general/document/document-certificate-download-button.tsx @@ -13,13 +13,13 @@ import { DownloadIcon } from 'lucide-react'; export type DocumentCertificateDownloadButtonProps = { className?: string; - documentId: number; + envelopeId: string; documentStatus: DocumentStatus; }; export const DocumentCertificateDownloadButton = ({ className, - documentId, + envelopeId, documentStatus, }: DocumentCertificateDownloadButtonProps) => { const { toast } = useToast(); @@ -29,7 +29,7 @@ export const DocumentCertificateDownloadButton = ({ const onDownloadCertificatesClick = async () => { try { - const { data, envelopeTitle } = await downloadCertificate({ documentId }); + const { data, envelopeTitle } = await downloadCertificate({ envelopeId }); const buffer = new Uint8Array(base64.decode(data)); const blob = new Blob([buffer], { type: 'application/pdf' }); diff --git a/apps/remix/app/components/general/document/embedded-editor-attachment-popover.tsx b/apps/remix/app/components/general/document/embedded-editor-attachment-popover.tsx index 009cb65fe..446d6e6a5 100644 --- a/apps/remix/app/components/general/document/embedded-editor-attachment-popover.tsx +++ b/apps/remix/app/components/general/document/embedded-editor-attachment-popover.tsx @@ -1,5 +1,6 @@ import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { nanoid } from '@documenso/lib/universal/id'; +import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form'; @@ -22,7 +23,7 @@ export type EmbeddedEditorAttachmentPopoverProps = { const ZAttachmentFormSchema = z.object({ label: z.string().min(1, 'Label is required'), - url: z.string().url('Must be a valid URL'), + url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'), }); type TAttachmentFormSchema = z.infer; @@ -117,7 +118,7 @@ export const EmbeddedEditorAttachmentPopover = ({

{attachment.label}

(null); + /** + * Whether the field was automatically selected on creation (drag-drop or marquee). + * + * We purposefully supress the floating toolbar for newly created fields. + */ + const [isAutoSelectedField, setIsAutoSelectedField] = useState(false); + const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer( ({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData, @@ -237,10 +244,26 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR fieldGroup.off('transformend'); fieldGroup.off('dragend'); - // Set up field selection. - fieldGroup.on('click', () => { + // Set up field selection. Shift + click toggles this field in/out of the current + // multi-selection, so fields can be added to a group by clicking them -- + // complementing marquee drag-selection. A plain click (no modifier) selects just + // this field. + fieldGroup.on('click', (event) => { removePendingField(); - setSelectedFields([fieldGroup]); + + const isMultiSelectModifier = event.evt.shiftKey; + + if (isMultiSelectModifier) { + const currentNodes = interactiveTransformer.current?.nodes() ?? []; + const isAlreadySelected = currentNodes.includes(fieldGroup); + + setSelectedFields( + isAlreadySelected ? currentNodes.filter((node) => node !== fieldGroup) : [...currentNodes, fieldGroup], + ); + } else { + setSelectedFields([fieldGroup]); + } + pageLayer.current?.batchDraw(); }); @@ -445,43 +468,18 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR } }); - // Clicks should select/deselect shapes + // Clicking empty stage area clears the selection. Field clicks -- including + // Shift+click multi-select -- are handled by each field group's own click + // handler in `unsafeRenderFieldOnLayer`. currentStage.on('click tap', (e) => { - // if we are selecting with rect, do nothing + // If we are selecting with the marquee rectangle, do nothing. if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) { return; } - // If empty area clicked, remove all selections + // If empty area clicked, remove all selections. if (e.target === stage.current) { setSelectedFields([]); - return; - } - - // Do nothing if field not clicked, or if field is not editable - if (!e.target.hasName('field-group') || e.target.draggable() === false) { - return; - } - - // do we pressed shift or ctrl? - const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey; - const isSelected = transformer.nodes().indexOf(e.target) >= 0; - - if (!metaPressed && !isSelected) { - // if no key pressed and the node is not selected - // select just one - setSelectedFields([e.target]); - } else if (metaPressed && isSelected) { - // if we pressed keys and node was selected - // we need to remove it from selection: - const nodes = transformer.nodes().slice(); // use slice to have new copy of array - // remove node from array - nodes.splice(nodes.indexOf(e.target), 1); - setSelectedFields(nodes); - } else if (metaPressed && !isSelected) { - // add the node into selection - const nodes = transformer.nodes().concat([e.target]); - setSelectedFields(nodes); } }); @@ -521,13 +519,48 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR setSelectedFields(liveSelectedFieldGroups); } + // Mirror the editor's single selected field onto the canvas (Konva) selection. + // + // `addField` already marks a newly created field as the selected field, so this + // makes a field placed via the palette (drag-drop) or marquee creation show its + // resize handles immediately -- no second click needed. It also clears the canvas + // selection when the selected field is cleared (e.g. when the author starts + // placing another field), so the floating action toolbar can't intercept the next + // placement click. Runs after the render loop above so the field's group exists. + const selectedFormId = editorFields.selectedField?.formId ?? null; + const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1; + + if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) { + const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId; + + if (!isAlreadySelected) { + const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`); + + if (fieldGroupToSelect instanceof Konva.Group) { + setSelectedFields([fieldGroupToSelect], { isAutoSelect: true }); + } + } + } else if (selectedFormId === null && isSingleCanvasSelection) { + setSelectedFields([]); + } + // Rerender the transformer interactiveTransformer.current?.forceUpdate(); pageLayer.current.batchDraw(); - }, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]); + }, [ + localPageFields, + selectedKonvaFieldGroups, + overlappingFieldFormIds, + isFieldChanging, + editorFields.selectedField?.formId, + ]); + + const setSelectedFields = (nodes: Konva.Node[], options?: { isAutoSelect?: boolean }) => { + // Any explicit (user-driven) selection shows the action toolbar; only auto-selection + // on field creation suppresses it. + setIsAutoSelectedField(Boolean(options?.isAutoSelect)); - const setSelectedFields = (nodes: Konva.Node[]) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const fieldGroups = nodes.filter( (node) => node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()), @@ -663,25 +696,30 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR return ( <> - {selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && ( - field.id())} - style={{ - position: 'absolute', - top: interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px', - left: interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px', - transform: 'translateX(-50%)', - gap: '8px', - pointerEvents: 'auto', - zIndex: 50, - }} - /> - )} + {selectedKonvaFieldGroups.length > 0 && + interactiveTransformer.current && + !isFieldChanging && + !isAutoSelectedField && ( + field.id())} + style={{ + position: 'absolute', + top: + interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px', + left: + interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px', + transform: 'translateX(-50%)', + gap: '8px', + pointerEvents: 'auto', + zIndex: 50, + }} + /> + )} {pendingFieldCreation && (
{ } /> + + {/* Document View */}
{envelope.recipients.length === 0 && ( diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx new file mode 100644 index 000000000..153db0a91 --- /dev/null +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-invalid-direct-template-alert.tsx @@ -0,0 +1,55 @@ +import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; +import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; +import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Trans } from '@lingui/react/macro'; +import { useMemo } from 'react'; + +export type EnvelopeEditorInvalidDirectTemplateAlertProps = { + className?: string; +}; + +/** + * Warns that a direct link template cannot be used because one or more signers + * are missing a signature field. + */ +export const EnvelopeEditorInvalidDirectTemplateAlert = ({ + className, +}: EnvelopeEditorInvalidDirectTemplateAlertProps) => { + const { envelope, isTemplate } = useCurrentEnvelopeEditor(); + + const signersMissingSignatureFields = useMemo(() => { + if (!isTemplate || !envelope.directLink?.enabled) { + return []; + } + + return getRecipientsWithMissingFields(envelope.recipients, envelope.fields); + }, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]); + + if (signersMissingSignatureFields.length === 0) { + return null; + } + + return ( + + + Invalid direct link template + + + + + Recipients cannot use this direct link template because the following signers are missing a signature field + + +
    + {signersMissingSignatureFields.map((recipient, i) => ( +
  • {recipient.email || recipient.name || `Recipient ${i + 1}`}
  • + ))} +
+
+
+ ); +}; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx index 42144b6f8..df5339360 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx @@ -22,6 +22,7 @@ import { match } from 'ts-pattern'; import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer'; import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; +import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert'; import { EnvelopeRendererFileSelector } from './envelope-file-selector'; export const EnvelopeEditorPreviewPage = () => { @@ -228,6 +229,8 @@ export const EnvelopeEditorPreviewPage = () => { {/* Horizontal envelope item selector */} + + Preview Mode diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index 6e8c64345..c6fb38938 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -26,6 +26,7 @@ import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog'; +import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert'; import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form'; import { EnvelopeItemTitleInput } from './envelope-editor-title-input'; @@ -449,6 +450,9 @@ export const EnvelopeEditorUploadPage = () => { return (
+ + + diff --git a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx index 90cfdfb79..3b1a0b188 100644 --- a/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx +++ b/apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx @@ -174,7 +174,7 @@ export const EnvelopeDropZoneWrapper = ({ children, type, className }: EnvelopeD {type === EnvelopeType.DOCUMENT ? Upload Document : Upload Template} -

+

Drag and drop your document here

diff --git a/apps/remix/app/components/general/organisation-usage-panel.tsx b/apps/remix/app/components/general/organisation-usage-panel.tsx index c9ed06265..99a69a661 100644 --- a/apps/remix/app/components/general/organisation-usage-panel.tsx +++ b/apps/remix/app/components/general/organisation-usage-panel.tsx @@ -1,13 +1,38 @@ import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period'; +import { + getQuotaUsagePercent, + isQuotaExceeded, + isQuotaNearing, + normalizeCapacityLimit, +} from '@documenso/lib/universal/quota-usage'; +import { cn } from '@documenso/ui/lib/utils'; +import type { BadgeProps } from '@documenso/ui/primitives/badge'; +import { Badge } from '@documenso/ui/primitives/badge'; import { Progress } from '@documenso/ui/primitives/progress'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select'; import { Trans } from '@lingui/react/macro'; import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client'; -import { useState } from 'react'; -import { match } from 'ts-pattern'; +import type { LucideIcon } from 'lucide-react'; +import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useId, useState } from 'react'; + import { OrganisationUsageResetButton } from './organisation-usage-reset-button'; +type CapacityUsage = { + members: number; + teams: number; +}; + +type UsageRow = { + counter: 'document' | 'email' | 'api'; + label: ReactNode; + icon: LucideIcon; + used: number; + effectiveLimit: number | null; +}; + type OrganisationUsagePanelProps = { organisationId: string; monthlyStats: Pick< @@ -15,13 +40,151 @@ type OrganisationUsagePanelProps = { 'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports' >[]; organisationClaim: OrganisationClaim; + capacityUsage?: CapacityUsage; +}; + +type UsageCardState = { + status: { + label: ReactNode; + variant: NonNullable; + }; + percent: number; + hasFiniteLimit: boolean; + progressClassName: string; + subtext: ReactNode; +}; + +type UsageCardStateOptions = { + used: number; + limit: number | null | undefined; + footnote?: ReactNode; +}; + +const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => { + const percent = getQuotaUsagePercent(used, limit ?? null); + const hasFiniteLimit = Boolean(limit && limit > 0); + + if (limit === null || limit === undefined) { + return { + status: { label: Unlimited, variant: 'neutral' }, + percent, + hasFiniteLimit, + progressClassName: '', + subtext: footnote ?? null, + }; + } + + if (limit === 0) { + return { + status: { label: Blocked, variant: 'destructive' }, + percent, + hasFiniteLimit, + progressClassName: '', + subtext: footnote ?? Resource blocked, + }; + } + + if (used > limit) { + return { + status: { label: Exceeded, variant: 'destructive' }, + percent, + hasFiniteLimit, + progressClassName: '[&>div]:bg-destructive', + subtext: footnote ?? null, + }; + } + + if (isQuotaExceeded(limit, used)) { + return { + status: { label: Limit reached, variant: 'orange' }, + percent, + hasFiniteLimit, + progressClassName: '[&>div]:bg-orange-500 dark:[&>div]:bg-orange-400', + subtext: footnote ?? null, + }; + } + + if (isQuotaNearing(limit, used)) { + return { + status: { label: Near limit, variant: 'warning' }, + percent, + hasFiniteLimit, + progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400', + subtext: footnote ?? null, + }; + } + + return { + status: { label: Within limit, variant: 'default' }, + percent, + hasFiniteLimit, + progressClassName: '', + subtext: footnote ?? null, + }; +}; + +type UsageStatCardProps = { + label: ReactNode; + icon: LucideIcon; + used: number; + limit: number | null | undefined; + /** When true the card is a plain counter with no limit, status or progress. */ + countOnly?: boolean; + footnote?: ReactNode; + action?: ReactNode; +}; + +const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => { + const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote }); + + return ( +
+
+
+ + {label} +
+ + {!countOnly && ( + + {status.label} + + )} +
+ +
+
+
+ + {used.toLocaleString()} + + {hasFiniteLimit ? ( + / {limit?.toLocaleString()} + ) : null} +
+ + {hasFiniteLimit ? ( + {percent}% + ) : null} +
+ + {hasFiniteLimit ? : null} + + {subtext ?

{subtext}

: null} +
+ + {action ?
{action}
: null} +
+ ); }; export const OrganisationUsagePanel = ({ organisationId, monthlyStats, organisationClaim, + capacityUsage, }: OrganisationUsagePanelProps) => { + const monthlyUsagePeriodId = useId(); const [selectedPeriod, setSelectedPeriod] = useState(() => monthlyStats[0]?.period); const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0]; @@ -30,86 +193,105 @@ export const OrganisationUsagePanel = ({ // current period), so only offer the reset action when viewing the current month. const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod(); - const rows = [ + const capacityRows = capacityUsage + ? [ + { + key: 'members', + label: Members, + icon: UsersIcon, + used: capacityUsage.members, + limit: normalizeCapacityLimit(organisationClaim.memberCount), + }, + { + key: 'teams', + label: Teams, + icon: UsersRoundIcon, + used: capacityUsage.teams, + limit: normalizeCapacityLimit(organisationClaim.teamCount), + }, + ] + : []; + + const monthlyRows: UsageRow[] = [ { - counter: 'document' as const, + counter: 'document', label: Documents, + icon: FileIcon, used: selectedStat?.documentCount ?? 0, effectiveLimit: organisationClaim.documentQuota, }, { - counter: 'email' as const, + counter: 'email', label: Emails, + icon: MailIcon, used: selectedStat?.emailCount ?? 0, effectiveLimit: organisationClaim.emailQuota, }, { - counter: 'api' as const, + counter: 'api', label: API requests, + icon: PlugIcon, used: selectedStat?.apiCount ?? 0, effectiveLimit: organisationClaim.apiQuota, }, ]; return ( -
-
-

- Usage for period: {selectedStat?.period || 'N/A'} -

+
+ {capacityRows.length > 0 ? ( +
+ {capacityRows.map((row) => ( + + ))} +
+ ) : null} - {monthlyStats.length > 0 && ( - - )} -
+
+
+

+ Monthly usage +

- {rows.map((row) => { - const percent = - row.effectiveLimit && row.effectiveLimit > 0 - ? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100)) - : 0; + {monthlyStats.length > 0 ? ( + + ) : null} +
- return ( -
-
- {row.label} - - {row.used} /{' '} - {match(row.effectiveLimit) - .with(null, () => Unlimited) - .with(0, () => Blocked) - .otherwise(String)} - -
+
+ {monthlyRows.map((row) => ( + + ) : undefined + } + /> + ))} - {row.effectiveLimit && row.effectiveLimit > 0 ? : null} - - {selectedStat && isCurrentPeriod && ( -
- -
- )} -
- ); - })} - -
-
- - Reports - - {selectedStat?.emailReports ?? 0} + Reports} + icon={MailOpenIcon} + used={selectedStat?.emailReports ?? 0} + limit={null} + countOnly + footnote={Sent this period} + />
diff --git a/apps/remix/app/components/general/organisation-usage-reset-button.tsx b/apps/remix/app/components/general/organisation-usage-reset-button.tsx index 7451f6e1c..bec8b8dd6 100644 --- a/apps/remix/app/components/general/organisation-usage-reset-button.tsx +++ b/apps/remix/app/components/general/organisation-usage-reset-button.tsx @@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { Trans, useLingui } from '@lingui/react/macro'; +import { RotateCcwIcon } from 'lucide-react'; import { useRevalidator } from 'react-router'; type OrganisationUsageResetButtonProps = { @@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi loading={isPending} onClick={() => reset({ organisationId, counter })} > + Reset ); diff --git a/apps/remix/app/components/general/rate-limit-array-input.tsx b/apps/remix/app/components/general/rate-limit-array-input.tsx index a2adaad38..a0197764f 100644 --- a/apps/remix/app/components/general/rate-limit-array-input.tsx +++ b/apps/remix/app/components/general/rate-limit-array-input.tsx @@ -1,7 +1,9 @@ +import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription'; import { Button } from '@documenso/ui/primitives/button'; import { Input } from '@documenso/ui/primitives/input'; -import { Trans } from '@lingui/react/macro'; +import { Trans, useLingui } from '@lingui/react/macro'; import { PlusIcon, Trash2Icon } from 'lucide-react'; +import { useState } from 'react'; type RateLimitEntryValue = { window: string; max: number }; @@ -11,50 +13,153 @@ type RateLimitArrayInputProps = { disabled?: boolean; }; +const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 }; + +/** A row counts as "started" once either field has input; fully-empty rows are dropped on commit. */ +const hasEntryInput = (entry: RateLimitEntryValue) => entry.window.trim() !== '' || entry.max > 0; + +/** Keep in-progress rows; drop rows that are completely empty. */ +const persistEntries = (entries: RateLimitEntryValue[]) => { + return entries.map((entry) => ({ ...entry, window: entry.window.trim() })).filter(hasEntryInput); +}; + export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => { - const entries = value ?? []; + const { t } = useLingui(); + const [draftEntry, setDraftEntry] = useState(null); + + const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY]; + + const getWindowError = (entry: RateLimitEntryValue, index: number) => { + const window = entry.window.trim(); + + if (!hasEntryInput(entry)) { + return null; + } + + if (window === '') { + return t`Enter a window, e.g. 5m`; + } + + if (!RATE_LIMIT_WINDOW_REGEX.test(window)) { + return t`Use a duration with a unit, e.g. 5m, 1h, or 24h`; + } + + const isDuplicateWindow = entries.some((otherEntry, otherIndex) => { + return otherIndex !== index && otherEntry.window.trim() === window; + }); + + return isDuplicateWindow ? t`Use a unique window for each rate limit` : null; + }; + + const getMaxError = (entry: RateLimitEntryValue) => { + if (!hasEntryInput(entry)) { + return null; + } + + return entry.max > 0 ? null : t`Enter a max request count greater than 0`; + }; const updateEntry = (index: number, patch: Partial) => { - const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)); - onChange(next); + if (index >= value.length) { + const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch }; + + if (hasEntryInput(nextDraftEntry)) { + onChange(persistEntries([...value, nextDraftEntry])); + setDraftEntry(null); + return; + } + + setDraftEntry(nextDraftEntry); + return; + } + + const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)); + onChange(persistEntries(next)); }; const removeEntry = (index: number) => { - onChange(entries.filter((_, i) => i !== index)); + if (index >= value.length) { + setDraftEntry(null); + return; + } + + const next = value.filter((_, i) => i !== index); + onChange(persistEntries(next)); }; const addEntry = () => { - onChange([...entries, { window: '5m', max: 100 }]); + setDraftEntry(EMPTY_ENTRY); }; + const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry)); + const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors; + return (
- {entries.map((entry, index) => ( -
- updateEntry(index, { window: e.target.value })} - /> - updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })} - /> - -
- ))} +
+ + Window + + + Max requests + +
- +
+ + {windowError ?

{windowError}

: null} + {maxError ?

{maxError}

: null} +
+ ); + })} + +
); diff --git a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx index 7feb0e191..180dc3e44 100644 --- a/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx +++ b/apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -29,7 +30,7 @@ export type SettingsSecurityPasskeyTableActionsProps = { }; const ZUpdatePasskeySchema = z.object({ - name: z.string(), + name: ZNameSchema, }); type TUpdatePasskeySchema = z.infer; diff --git a/apps/remix/app/entry.client.tsx b/apps/remix/app/entry.client.tsx index 86e949d7a..0ffb3602e 100644 --- a/apps/remix/app/entry.client.tsx +++ b/apps/remix/app/entry.client.tsx @@ -3,27 +3,32 @@ import { dynamicActivate } from '@documenso/lib/utils/i18n'; import { i18n } from '@lingui/core'; import { detect, fromHtmlTag } from '@lingui/detect-locale'; import { I18nProvider } from '@lingui/react'; -import { StrictMode, startTransition, useEffect } from 'react'; +import { StrictMode, startTransition } from 'react'; import { hydrateRoot } from 'react-dom/client'; import { HydratedRouter } from 'react-router/dom'; import './utils/polyfills/promise-with-resolvers'; -function PosthogInit() { +/** + * Initialised imperatively (not as a component inside `hydrateRoot`) because + * rendering extra client-only siblings changes the React tree structure + * relative to the server render in `entry.server.tsx`. That shifts every + * `useId` value (used by Radix for `id`/`htmlFor`/`aria-*`), causing hydration + * mismatches which can abort hydration entirely when the user interacts with + * the page early, leaving dead event handlers (broken dropdowns, native form + * submits). + */ +function initPosthog() { const postHogConfig = extractPostHogConfig(); - useEffect(() => { - if (postHogConfig) { - void import('posthog-js').then(({ default: posthog }) => { - posthog.init(postHogConfig.key, { - api_host: postHogConfig.host, - capture_exceptions: true, - }); + if (postHogConfig) { + void import('posthog-js').then(({ default: posthog }) => { + posthog.init(postHogConfig.key, { + api_host: postHogConfig.host, + capture_exceptions: true, }); - } - }, []); - - return null; + }); + } } async function main() { @@ -38,11 +43,11 @@ async function main() { - - , ); }); + + void initPosthog(); } // eslint-disable-next-line @typescript-eslint/no-floating-promises diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index 096b1504e..a7c29b4be 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -119,7 +119,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) { const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+')); return ( - + // `suppressHydrationWarning` because `remix-themes` intentionally mutates + // `data-theme`/`class` on before hydration (PreventFlashOnWrongTheme), + // so the server-rendered attributes never match the client render when the + // theme is resolved from the system preference. Attribute-only, one level deep. + @@ -173,7 +177,11 @@ export function LayoutContent({ children }: { children: React.ReactNode }) { ', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', +]; + +test('[ATTACHMENTS]: rejects attachment URLs with a non-http(s) protocol', async ({ request }) => { + const { team, owner } = await seedTeam(); + const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id); + + for (const url of NON_HTTP_URLS) { + const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'attachment', data: url } }, + }); + + expect(res.ok(), `expected ${url} to be rejected`).toBe(false); + } + + const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } }); + expect(attachments).toHaveLength(0); +}); + +test('[ATTACHMENTS]: accepts attachment URLs with an http(s) protocol', async ({ request }) => { + const { team, owner } = await seedTeam(); + const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id); + + const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'safe', data: 'https://example.com/file.pdf' } }, + }); + + expect(res.ok()).toBe(true); + + const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } }); + expect(attachments).toHaveLength(1); + expect(attachments[0].data).toBe('https://example.com/file.pdf'); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/attachment-visibility-access.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/attachment-visibility-access.spec.ts new file mode 100644 index 000000000..f47a76c1f --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/attachment-visibility-access.spec.ts @@ -0,0 +1,121 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { hashString } from '@documenso/lib/server-only/auth/hash'; +import { alphaid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { type APIRequestContext, expect, test } from '@playwright/test'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ mode: 'parallel' }); + +const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => { + const token = `api_${alphaid(16)}`; + + await prisma.apiToken.create({ + data: { name: 'attachment-access-test', token: hashString(token), expires: null, userId, teamId }, + }); + + return { token }; +}; + +const canReadEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => { + const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + return res.ok(); +}; + +/** + * Attachment create/update/delete/list must enforce document visibility, not + * just team membership. A member whose visibility tier excludes a restricted + * envelope must not be able to read or mutate its attachments. + */ +test('[ATTACHMENTS]: a member cannot create or delete attachments on a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false); + + const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'attachment', data: 'https://example.com' } }, + }); + + expect(createRes.ok()).toBe(false); + + // No attachment should have been created. + const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } }); + expect(attachments).toHaveLength(0); +}); + +test('[ATTACHMENTS]: a member cannot update an attachment on a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + // The owner (who can see the document) creates the attachment. + const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'original', data: 'https://example.com/original' } }, + }); + expect(createRes.ok()).toBe(true); + const attachment = await createRes.json(); + + expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false); + + const updateRes = await request.post(`${API_BASE_URL}/envelope/attachment/update`, { + headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' }, + data: { id: attachment.id, data: { label: 'tampered', data: 'https://example.com/tampered' } }, + }); + + expect(updateRes.ok()).toBe(false); + + const persisted = await prisma.envelopeAttachment.findUnique({ where: { id: attachment.id } }); + expect(persisted?.label).toBe('original'); +}); + +test('[ATTACHMENTS]: a member cannot list attachments on a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'restricted', data: 'https://example.com/restricted' } }, + }); + + expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false); + + const findRes = await request.get(`${API_BASE_URL}/envelope/attachment?envelopeId=${envelope.id}`, { + headers: { Authorization: `Bearer ${memberToken}` }, + }); + + expect(findRes.ok()).toBe(false); + + const body = findRes.ok() ? await findRes.json() : null; + const attachments = body?.data ?? []; + expect(attachments).toHaveLength(0); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts new file mode 100644 index 000000000..36af4cad2 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-direct-template-validation.spec.ts @@ -0,0 +1,76 @@ +import { seedDirectTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, type Page, test } from '@playwright/test'; + +import { apiSignin } from '../fixtures/authentication'; +import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor'; + +const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template'; + +/** + * Place a field on the PDF canvas in the envelope editor. + */ +const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => { + await root.getByRole('button', { name: fieldName, exact: true }).click(); + + const canvas = root.locator('.konva-container canvas').first(); + await expect(canvas).toBeVisible(); + await canvas.click({ position }); +}; + +/** + * Seed a V2 direct template and open it in the native template editor. + * + * Only the native template editor is covered here: direct links only exist + * for templates and are not part of the embedded editor surfaces. + */ +const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => { + const { user, team } = await seedUser(); + + const template = await seedDirectTemplate({ + title: `E2E Direct Template Validation ${Date.now()}`, + userId: user.id, + teamId: team.id, + internalVersion: 2, + createDirectRecipientSignatureField: options.createDirectRecipientSignatureField, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates/${template.id}/edit`, + }); + + return { user, team, template }; +}; + +test.describe('template editor', () => { + test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => { + await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false }); + + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible(); + await expect(page.getByText('are missing a signature field')).toBeVisible(); + }); + + test('does not show the warning when all signers have signature fields', async ({ page }) => { + await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true }); + + // Wait for the editor to render before asserting the banner is absent. + await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible(); + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible(); + }); + + test('warning disappears after placing a signature field', async ({ page }) => { + await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false }); + + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible(); + + // Place a signature field for the direct recipient (auto-selected single recipient). + await clickEnvelopeEditorStep(page, 'addFields'); + await expect(page.locator('.konva-container canvas').first()).toBeVisible(); + await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 }); + + // The banner clears once the field is autosaved and the envelope state updates. + await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts index b4130a358..0a4c3ab73 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts @@ -20,7 +20,7 @@ import { type TEnvelopeEditorSurface, } from '../fixtures/envelope-editor'; import { expectToastTextToBeVisible } from '../fixtures/generic'; -import { getKonvaElementCountForPage } from '../fixtures/konva'; +import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva'; type TFieldFlowResult = { externalId: string; @@ -46,6 +46,7 @@ const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: str if (!surface.isEmbedded) { await expectToastTextToBeVisible(surface.root, 'Envelope updated'); + await surface.root.getByTestId('toast-close').click(); } }; @@ -98,6 +99,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number await canvas.click({ position, force: true }); }; +/** + * Shift+click a field on the canvas to toggle it in/out of the current multi-selection. + */ +const shiftClickFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => { + const canvas = root.locator('.konva-container canvas').first(); + await expect(canvas).toBeVisible(); + await root.waitForTimeout(300); + // Use force:true to bypass any floating action toolbar buttons that may intercept clicks. + await canvas.click({ position, modifiers: ['Shift'], force: true }); +}; + const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise => { const externalId = `e2e-fields-${nanoid()}`; @@ -760,9 +772,106 @@ const assertChangeFieldTypePersistedInDatabase = async ({ expect(actualMetaTypes).toEqual(['date', 'date']); }; +// --- Shift+click multi-select flow --- + +type TShiftClickFlowResult = { + externalId: string; +}; + +const SHIFT_CLICK_FIELD_POSITIONS = { + signature: { x: 150, y: 120 }, + text: { x: 150, y: 260 }, + name: { x: 150, y: 400 }, +}; + +const runShiftClickMultiSelectFlow = async (surface: TEnvelopeEditorSurface): Promise => { + const externalId = `e2e-shift-click-${nanoid()}`; + const root = surface.root; + + if (surface.isEmbedded && !surface.envelopeId) { + await addEnvelopeItemPdf(root, 'embedded-fields.pdf'); + } + + await updateExternalId(surface, externalId); + await setupRecipientsForFieldPlacement(surface); + + await clickEnvelopeEditorStep(root, 'addFields'); + await expect(root.locator('.konva-container canvas').first()).toBeVisible(); + + // Place three fields, spaced far enough apart that their action toolbars don't + // overlap a neighbouring field's click target. + await placeFieldOnPdf(root, 'Signature', SHIFT_CLICK_FIELD_POSITIONS.signature); + await placeFieldOnPdf(root, 'Text', SHIFT_CLICK_FIELD_POSITIONS.text); + await placeFieldOnPdf(root, 'Name', SHIFT_CLICK_FIELD_POSITIONS.name); + expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3); + + // A plain click selects exactly one field. + await selectFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature); + await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1); + + // Shift+click a second field ADDS it to the selection (the new behaviour). + await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.text); + await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2); + + // Shift+click an already-selected field REMOVES it from the selection. + await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature); + await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1); + + // Shift+click it again RE-ADDS it, leaving Signature + Text selected and Name excluded. + await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature); + await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2); + + // Delete the two selected fields via the floating action toolbar. Only the + // un-selected Name field should remain -- proving the multi-selection contained + // exactly the two Shift-clicked fields. + await expect(root.locator('button[title="Remove"]')).toBeVisible(); + await root.locator('button[title="Remove"]').click(); + expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1); + + // Navigate away and back to verify persistence. + await clickEnvelopeEditorStep(root, 'upload'); + await clickEnvelopeEditorStep(root, 'addFields'); + expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1); + + return { externalId }; +}; + +const assertShiftClickMultiSelectPersistedInDatabase = async ({ + surface, + externalId, +}: { + surface: TEnvelopeEditorSurface; + externalId: string; +}) => { + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + externalId, + userId: surface.userId, + teamId: surface.teamId, + type: surface.envelopeType, + }, + orderBy: { createdAt: 'desc' }, + include: { fields: true }, + }); + + // Signature + Text were multi-selected via Shift+click and deleted; only Name remains. + expect(envelope.fields).toHaveLength(1); + expect(envelope.fields[0].type).toBe(FieldType.NAME); +}; + // --- Test describe blocks --- test.describe('document editor', () => { + test('shift+click adds and removes fields from the selection', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const result = await runShiftClickMultiSelectFlow(surface); + + await assertShiftClickMultiSelectPersistedInDatabase({ + surface, + ...result, + }); + }); + test('add and persist signature/text fields', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const result = await runAddAndPersistSignatureTextFields(surface); @@ -815,6 +924,16 @@ test.describe('document editor', () => { }); test.describe('template editor', () => { + test('shift+click adds and removes fields from the selection', async ({ page }) => { + const surface = await openTemplateEnvelopeEditor(page); + const result = await runShiftClickMultiSelectFlow(surface); + + await assertShiftClickMultiSelectPersistedInDatabase({ + surface, + ...result, + }); + }); + test('add and persist signature/text fields', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); const result = await runAddAndPersistSignatureTextFields(surface); @@ -867,6 +986,21 @@ test.describe('template editor', () => { }); test.describe('embedded create', () => { + test('shift+click adds and removes fields from the selection', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'DOCUMENT', + tokenNamePrefix: 'e2e-embed-shift-click', + }); + const result = await runShiftClickMultiSelectFlow(surface); + + await persistEmbeddedEnvelope(surface); + + await assertShiftClickMultiSelectPersistedInDatabase({ + surface, + ...result, + }); + }); + test('add and persist signature/text fields', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'DOCUMENT', @@ -944,6 +1078,22 @@ test.describe('embedded create', () => { }); test.describe('embedded edit', () => { + test('shift+click adds and removes fields from the selection', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'TEMPLATE', + mode: 'edit', + tokenNamePrefix: 'e2e-embed-shift-click', + }); + const result = await runShiftClickMultiSelectFlow(surface); + + await persistEmbeddedEnvelope(surface); + + await assertShiftClickMultiSelectPersistedInDatabase({ + surface, + ...result, + }); + }); + test('add and persist signature/text fields', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'TEMPLATE', diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-autosave-race.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-autosave-race.spec.ts new file mode 100644 index 000000000..1d1035527 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-autosave-race.spec.ts @@ -0,0 +1,199 @@ +import { prisma } from '@documenso/prisma'; +import { expect, type Page, test } from '@playwright/test'; + +import { + clickAddSignerButton, + clickEnvelopeEditorStep, + getRecipientEmailInputs, + openDocumentEnvelopeEditor, + setRecipientEmail, + setRecipientName, + type TEnvelopeEditorSurface, +} from '../fixtures/envelope-editor'; + +/** + * Reproduction for the recipient autosave race condition. + * + * Symptom (production only, where there is real network lag): + * 1. The author adds a recipient and types its name/email. + * 2. They navigate to the "Add Fields" step. + * 3. The recipient selector shows the default "Recipient 1" placeholder + * instead of the recipient they just typed, and the typed name/email is + * silently lost. + * + * Theory (see packages/lib/client-only/hooks/use-envelope-autosave.ts): + * When the author navigates, `flushAutosave()` is awaited before the Add + * Fields page renders. If an *earlier* (empty) recipient save is still + * in-flight at that moment, `flush()` awaits that in-flight save and returns + * WITHOUT committing the newer typed data sitting in `lastArgsRef` (whose + * debounce timer it just cleared). The typed data is dropped, the empty + * recipient persists, and the selector renders "Recipient 1". + * + * This only happens when a save is still in-flight at navigation time, which is + * why it never reproduces locally (fast saves) but does on a laggy network. + * + * The test below simulates that lag by holding the first `envelope.recipient.set` + * request open. It asserts the CORRECT behaviour (typed recipient survives), so + * it is RED while the bug exists and GREEN once the autosave hook is fixed. + */ + +const RECIPIENT_SET_PROCEDURE = 'envelope.recipient.set'; + +// How long to hold the first recipient autosave "in-flight" to emulate prod lag. +const SIMULATED_NETWORK_LAG_MS = 5000; + +const FIRST_RECIPIENT = { + name: 'Alice Author', + email: 'alice-autosave-race@example.com', +}; + +const SECOND_RECIPIENT = { + name: 'Bob Builder', + email: 'bob-autosave-race@example.com', +}; + +type RecipientSetLagHandle = { + /** Resolves the instant the first recipient.set request is in-flight on the client. */ + firstRecipientSetInFlight: Promise; + /** Raw request bodies of every recipient.set call we intercepted. */ + recipientSetRequestBodies: string[]; +}; + +/** + * Installs a fake "production network lag" on the recipient autosave mutation. + * + * Only the FIRST recipient.set request is held open for `lagMs` (this is the save + * that must still be in-flight at navigation time for the race to occur). It + * resolves `firstRecipientSetInFlight` the instant it is intercepted so the test + * can keep typing while that save is pending. Subsequent recipient.set requests + * (e.g. the follow-up save the fixed hook issues) are forwarded immediately so the + * test does not pay the lag twice. + */ +const installRecipientSetLag = async (page: Page, lagMs: number): Promise => { + let markFirstInFlight: () => void = () => {}; + + const firstRecipientSetInFlight = new Promise((resolve) => { + markFirstInFlight = resolve; + }); + + const recipientSetRequestBodies: string[] = []; + + await page.route('**/api/trpc/**', async (route) => { + const request = route.request(); + + if (request.method() !== 'POST' || !request.url().includes(RECIPIENT_SET_PROCEDURE)) { + await route.continue(); + return; + } + + const callIndex = recipientSetRequestBodies.length + 1; + recipientSetRequestBodies.push(request.postData() ?? ''); + + if (callIndex === 1) { + // eslint-disable-next-line no-console + console.log(`[test] holding first ${RECIPIENT_SET_PROCEDURE} for ${lagMs}ms (simulated network lag)`); + + // The empty save is now in-flight from the client's perspective. + markFirstInFlight(); + + await new Promise((resolve) => setTimeout(resolve, lagMs)); + } else { + // eslint-disable-next-line no-console + console.log(`[test] forwarding ${RECIPIENT_SET_PROCEDURE} #${callIndex} (no lag)`); + } + + await route.continue(); + }); + + return { firstRecipientSetInFlight, recipientSetRequestBodies }; +}; + +const assertEnvelopeRecipientsPersisted = async (surface: TEnvelopeEditorSurface) => { + if (!surface.envelopeId) { + throw new Error('Expected the document editor surface to have an envelopeId'); + } + + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { id: surface.envelopeId }, + include: { + recipients: { + orderBy: { signingOrder: 'asc' }, + }, + }, + }); + + const persistedEmails = envelope.recipients.map((recipient) => recipient.email).filter(Boolean); + + // eslint-disable-next-line no-console + console.log( + '[test] persisted recipients:', + JSON.stringify( + envelope.recipients.map((recipient) => ({ name: recipient.name, email: recipient.email })), + null, + 2, + ), + ); + + expect(persistedEmails).toContain(FIRST_RECIPIENT.email); + expect(persistedEmails).toContain(SECOND_RECIPIENT.email); +}; + +test.describe('envelope editor recipient autosave race (network lag)', () => { + test('document editor: typed recipient survives navigation to Add Fields', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + + const { firstRecipientSetInFlight, recipientSetRequestBodies } = await installRecipientSetLag( + page, + SIMULATED_NETWORK_LAG_MS, + ); + + // 1. Add a second signer row. A blank document already has one empty default + // signer, so this schedules an autosave of TWO empty recipients + // (name='' / email='') - this is the save that will be in-flight. + await clickAddSignerButton(surface.root); + await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2); + + // 2. Wait until that empty autosave is actually in-flight on the client. This + // is the precondition the bug needs: a slow save holding the autosave lock. + await firstRecipientSetInFlight; + + // 3. The author now fills in the recipients they are adding. + await setRecipientName(surface.root, 0, FIRST_RECIPIENT.name); + await setRecipientEmail(surface.root, 0, FIRST_RECIPIENT.email); + await setRecipientName(surface.root, 1, SECOND_RECIPIENT.name); + await setRecipientEmail(surface.root, 1, SECOND_RECIPIENT.email); + + // 4. Immediately navigate to Add Fields (before the typed data's debounce + // fires). flushAutosave() awaits the in-flight EMPTY save; with the bug + // present it returns without ever committing the typed data. + await clickEnvelopeEditorStep(surface.root, 'addFields'); + + // 5. Wait for the Add Fields page to render (after the lagged flush resolves). + await expect(surface.root.getByText('Selected Recipient')).toBeVisible({ + timeout: SIMULATED_NETWORK_LAG_MS + 15000, + }); + + // Diagnostics - the request bodies show what actually reached the server. + // Buggy: only the first (empty) save is ever sent. Fixed: a follow-up save + // carrying the typed recipients is sent too. + // eslint-disable-next-line no-console + console.log('\n===== AUTOSAVE RACE DIAGNOSTICS ====='); + // eslint-disable-next-line no-console + console.log(`recipient.set requests sent to server: ${recipientSetRequestBodies.length}`); + // eslint-disable-next-line no-console + console.log( + `server ever received "${FIRST_RECIPIENT.email}": ${recipientSetRequestBodies.some((body) => body.includes(FIRST_RECIPIENT.email))}`, + ); + // eslint-disable-next-line no-console + console.log('=====================================\n'); + + // 6. THE USER-VISIBLE BUG: the selected recipient must be the one we typed + // (Alice), not the default "Recipient 1" placeholder. + const selectedRecipientSection = surface.root.locator('section').filter({ hasText: 'Selected Recipient' }); + + await expect(selectedRecipientSection.getByRole('combobox')).toContainText(FIRST_RECIPIENT.name); + + // 7. THE DATA LOSS: the typed recipients must actually be persisted. + await assertEnvelopeRecipientsPersisted(surface); + }); +}); diff --git a/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts b/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts index 806585193..fa1545d17 100644 --- a/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts +++ b/packages/app-tests/e2e/envelopes/envelope-expiration-settings.spec.ts @@ -19,7 +19,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level' }); // Wait for the form to load. - await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible(); + await expect(page.getByTestId('document-language-trigger')).toBeVisible(); // Change the amount to 2. const amountInput = page.getByTestId('envelope-expiration-amount'); @@ -35,7 +35,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level' await unitTrigger.click(); await page.getByRole('option', { name: 'Weeks' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); // Verify via database. @@ -57,14 +57,14 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({ redirectPath: `/o/${organisation.url}/settings/document`, }); - await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible(); + await expect(page.getByTestId('document-language-trigger')).toBeVisible(); // Find the mode select (shows "Custom duration") and change to "Never expires". const modeTrigger = page.getByTestId('envelope-expiration-mode'); await modeTrigger.click(); await page.getByRole('option', { name: 'Never expires' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); // Verify via database. @@ -109,7 +109,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p redirectPath: `/t/${team.url}/settings/document`, }); - await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible(); + await expect(page.getByTestId('document-language-trigger')).toBeVisible(); // The expiration picker mode select should show "Inherit from organisation" by default. const modeTrigger = page.getByTestId('envelope-expiration-mode'); @@ -128,7 +128,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p await unitTrigger.click(); await page.getByRole('option', { name: 'Days' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); // Verify team setting is overridden. diff --git a/packages/app-tests/e2e/features/include-document-certificate.spec.ts b/packages/app-tests/e2e/features/include-document-certificate.spec.ts index 335fb2389..6d44581f6 100644 --- a/packages/app-tests/e2e/features/include-document-certificate.spec.ts +++ b/packages/app-tests/e2e/features/include-document-certificate.spec.ts @@ -324,10 +324,7 @@ test.describe('Signing Certificate Tests', () => { .click(); await page.getByRole('option', { name: 'No' }).click(); - await page - .getByRole('button', { name: /Update/ }) - .first() - .click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await page.waitForTimeout(1000); @@ -347,10 +344,7 @@ test.describe('Signing Certificate Tests', () => { .getByRole('combobox') .click(); await page.getByRole('option', { name: 'Yes' }).click(); - await page - .getByRole('button', { name: /Update/ }) - .first() - .click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await page.waitForTimeout(1000); diff --git a/packages/app-tests/e2e/fixtures/konva.ts b/packages/app-tests/e2e/fixtures/konva.ts index 316eb46f4..87973ab97 100644 --- a/packages/app-tests/e2e/fixtures/konva.ts +++ b/packages/app-tests/e2e/fixtures/konva.ts @@ -16,3 +16,35 @@ export const getKonvaElementCountForPage = async (page: Page, pageNumber: number { pageNumber, elementSelector }, ); }; + +/** + * Returns how many field groups are currently attached to the page's Konva + * transformer, i.e. the size of the active canvas selection. Used to assert + * multi-select behaviour (marquee drag and Shift+click). + */ +export const getKonvaTransformerNodeCountForPage = async (page: Page, pageNumber: number) => { + await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' }); + + return await page.evaluate( + ({ pageNumber }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva; + + const stage = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`); + + if (!stage) { + return 0; + } + + const transformer = stage.find('Transformer')[0]; + + if (!transformer) { + return 0; + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return (transformer as Konva.Transformer).nodes().length; + }, + { pageNumber }, + ); +}; diff --git a/packages/app-tests/e2e/organisations/manage-organisation.spec.ts b/packages/app-tests/e2e/organisations/manage-organisation.spec.ts index f14777b08..23fcdedc6 100644 --- a/packages/app-tests/e2e/organisations/manage-organisation.spec.ts +++ b/packages/app-tests/e2e/organisations/manage-organisation.spec.ts @@ -60,7 +60,7 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => { await page.getByLabel('Organisation URL*').clear(); await page.getByLabel('Organisation URL*').fill(updatedOrganisationId); - await page.getByRole('button', { name: 'Update organisation' }).click(); + await page.getByRole('button', { name: 'Save changes' }).click(); // Check we have been redirected to the new organisation URL and the name is updated. await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`); diff --git a/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts b/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts new file mode 100644 index 000000000..23483a48e --- /dev/null +++ b/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts @@ -0,0 +1,406 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, type Page, test } from '@playwright/test'; +import { OrganisationGroupType, type OrganisationMemberRole } from '@prisma/client'; + +import { apiSignin } from '../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +/** + * Calls a tRPC mutation directly using the cookies of whoever is currently + * signed in on the page context. This deliberately bypasses the UI: the + * authorisation checks under test live on the server, and the UI may simply + * hide a button rather than reject the request, which would mask a backend gap. + */ +const trpcMutation = async (page: Page, procedure: string, input: Record) => { + return await page.request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ json: input }), + }); +}; + +const getOrganisationMember = async (userId: number, organisationId: string) => { + return await prisma.organisationMember.findFirstOrThrow({ + where: { + userId, + organisationId, + }, + }); +}; + +const createCustomGroup = async (organisationId: string, organisationRole: OrganisationMemberRole) => { + return await prisma.organisationGroup.create({ + data: { + id: generateDatabaseId('org_group'), + organisationId, + name: `custom-${organisationRole}-${nanoid()}`, + type: OrganisationGroupType.CUSTOM, + organisationRole, + }, + }); +}; + +const createPendingInvite = async (organisationId: string, organisationRole: OrganisationMemberRole) => { + return await prisma.organisationMemberInvite.create({ + data: { + id: generateDatabaseId('member_invite'), + email: `invite-${nanoid()}@test.documenso.com`, + token: nanoid(32), + organisationId, + organisationRole, + }, + }); +}; + +test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: member deletion', () => { + test('a manager cannot delete an admin via member.delete', async ({ page }) => { + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [managerUser, adminUser] = await seedOrganisationMembers({ + members: [ + { name: 'Manager', organisationRole: 'MANAGER' }, + { name: 'Admin', organisationRole: 'ADMIN' }, + ], + organisationId: organisation.id, + }); + + const adminMember = await getOrganisationMember(adminUser.id, organisation.id); + + await apiSignin({ page, email: managerUser.email }); + + const res = await trpcMutation(page, 'organisation.member.delete', { + organisationId: organisation.id, + organisationMemberId: adminMember.id, + }); + + expect(res.ok()).toBeFalsy(); + + // The admin must still be a member of the organisation. + const stillExists = await prisma.organisationMember.findFirst({ + where: { id: adminMember.id }, + }); + + expect(stillExists).not.toBeNull(); + }); + + test('a manager cannot delete an admin via member.deleteMany', async ({ page }) => { + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [managerUser, adminUser] = await seedOrganisationMembers({ + members: [ + { name: 'Manager', organisationRole: 'MANAGER' }, + { name: 'Admin', organisationRole: 'ADMIN' }, + ], + organisationId: organisation.id, + }); + + const adminMember = await getOrganisationMember(adminUser.id, organisation.id); + + await apiSignin({ page, email: managerUser.email }); + + const res = await trpcMutation(page, 'organisation.member.deleteMany', { + organisationId: organisation.id, + organisationMemberIds: [adminMember.id], + }); + + expect(res.ok()).toBeFalsy(); + + const stillExists = await prisma.organisationMember.findFirst({ + where: { id: adminMember.id }, + }); + + expect(stillExists).not.toBeNull(); + }); + + test('a manager cannot delete the organisation owner', async ({ page }) => { + const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [managerUser] = await seedOrganisationMembers({ + members: [{ name: 'Manager', organisationRole: 'MANAGER' }], + organisationId: organisation.id, + }); + + const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id); + + await apiSignin({ page, email: managerUser.email }); + + const res = await trpcMutation(page, 'organisation.member.deleteMany', { + organisationId: organisation.id, + organisationMemberIds: [ownerMember.id], + }); + + expect(res.ok()).toBeFalsy(); + + const stillExists = await prisma.organisationMember.findFirst({ + where: { id: ownerMember.id }, + }); + + expect(stillExists).not.toBeNull(); + }); + + test('an admin cannot delete the organisation owner', async ({ page }) => { + const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [adminUser] = await seedOrganisationMembers({ + members: [{ name: 'Admin', organisationRole: 'ADMIN' }], + organisationId: organisation.id, + }); + + const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id); + + await apiSignin({ page, email: adminUser.email }); + + const res = await trpcMutation(page, 'organisation.member.deleteMany', { + organisationId: organisation.id, + organisationMemberIds: [ownerMember.id], + }); + + expect(res.ok()).toBeFalsy(); + + const stillExists = await prisma.organisationMember.findFirst({ + where: { id: ownerMember.id }, + }); + + expect(stillExists).not.toBeNull(); + }); + + test('a manager can still delete a regular member (positive control)', async ({ page }) => { + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [managerUser, memberUser] = await seedOrganisationMembers({ + members: [ + { name: 'Manager', organisationRole: 'MANAGER' }, + { name: 'Member', organisationRole: 'MEMBER' }, + ], + organisationId: organisation.id, + }); + + const member = await getOrganisationMember(memberUser.id, organisation.id); + + await apiSignin({ page, email: managerUser.email }); + + const res = await trpcMutation(page, 'organisation.member.deleteMany', { + organisationId: organisation.id, + organisationMemberIds: [member.id], + }); + + expect(res.ok()).toBeTruthy(); + + const deleted = await prisma.organisationMember.findFirst({ + where: { id: member.id }, + }); + + expect(deleted).toBeNull(); + }); +}); + +test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group deletion', () => { + test('a manager cannot delete an admin-role group', async ({ page }) => { + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [managerUser] = await seedOrganisationMembers({ + members: [{ name: 'Manager', organisationRole: 'MANAGER' }], + organisationId: organisation.id, + }); + + const adminGroup = await createCustomGroup(organisation.id, 'ADMIN'); + + await apiSignin({ page, email: managerUser.email }); + + const res = await trpcMutation(page, 'organisation.group.delete', { + organisationId: organisation.id, + groupId: adminGroup.id, + }); + + expect(res.ok()).toBeFalsy(); + + const stillExists = await prisma.organisationGroup.findFirst({ + where: { id: adminGroup.id }, + }); + + expect(stillExists).not.toBeNull(); + }); + + test('a manager can delete a member-role group (positive control)', async ({ page }) => { + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [managerUser] = await seedOrganisationMembers({ + members: [{ name: 'Manager', organisationRole: 'MANAGER' }], + organisationId: organisation.id, + }); + + const memberGroup = await createCustomGroup(organisation.id, 'MEMBER'); + + await apiSignin({ page, email: managerUser.email }); + + const res = await trpcMutation(page, 'organisation.group.delete', { + organisationId: organisation.id, + groupId: memberGroup.id, + }); + + expect(res.ok()).toBeTruthy(); + + const deleted = await prisma.organisationGroup.findFirst({ + where: { id: memberGroup.id }, + }); + + expect(deleted).toBeNull(); + }); +}); + +test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: invite resend', () => { + test('a manager cannot resend an admin-role invite', async ({ page }) => { + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [managerUser] = await seedOrganisationMembers({ + members: [{ name: 'Manager', organisationRole: 'MANAGER' }], + organisationId: organisation.id, + }); + + const adminInvite = await createPendingInvite(organisation.id, 'ADMIN'); + + await apiSignin({ page, email: managerUser.email }); + + const res = await trpcMutation(page, 'organisation.member.invite.resend', { + organisationId: organisation.id, + invitationId: adminInvite.id, + }); + + expect(res.ok()).toBeFalsy(); + }); + + test('a manager can resend a member-role invite (positive control)', async ({ page }) => { + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [managerUser] = await seedOrganisationMembers({ + members: [{ name: 'Manager', organisationRole: 'MANAGER' }], + organisationId: organisation.id, + }); + + const memberInvite = await createPendingInvite(organisation.id, 'MEMBER'); + + await apiSignin({ page, email: managerUser.email }); + + const res = await trpcMutation(page, 'organisation.member.invite.resend', { + organisationId: organisation.id, + invitationId: memberInvite.id, + }); + + expect(res.ok()).toBeTruthy(); + }); +}); + +test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', () => { + test('the owner cannot leave without transferring ownership first', async ({ page }) => { + const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false }); + + const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id); + + await apiSignin({ page, email: ownerUser.email }); + + const res = await trpcMutation(page, 'organisation.leave', { + organisationId: organisation.id, + }); + + expect(res.ok()).toBeFalsy(); + + const stillExists = await prisma.organisationMember.findFirst({ + where: { id: ownerMember.id }, + }); + + expect(stillExists).not.toBeNull(); + }); + + test('a non-owner member can still leave (positive control)', async ({ page }) => { + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [memberUser] = await seedOrganisationMembers({ + members: [{ name: 'Member', organisationRole: 'MEMBER' }], + organisationId: organisation.id, + }); + + const member = await getOrganisationMember(memberUser.id, organisation.id); + + await apiSignin({ page, email: memberUser.email }); + + const res = await trpcMutation(page, 'organisation.leave', { + organisationId: organisation.id, + }); + + expect(res.ok()).toBeTruthy(); + + const deleted = await prisma.organisationMember.findFirst({ + where: { id: member.id }, + }); + + expect(deleted).toBeNull(); + }); +}); + +test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group membership scoping', () => { + test('cannot add a member from another organisation to a group', async ({ page }) => { + // Organisation A, where the actor is the owner/admin. + const { user: actor, organisation: organisationA } = await seedUser({ + isPersonalOrganisation: false, + }); + + // A separate organisation B with a member the actor has no authority over. + const { organisation: organisationB } = await seedUser({ isPersonalOrganisation: false }); + const [foreignUser] = await seedOrganisationMembers({ + members: [{ name: 'Foreign', organisationRole: 'MEMBER' }], + organisationId: organisationB.id, + }); + + const foreignMember = await getOrganisationMember(foreignUser.id, organisationB.id); + + // A custom group the actor legitimately controls in organisation A. + const groupA = await createCustomGroup(organisationA.id, 'MEMBER'); + + await apiSignin({ page, email: actor.email }); + + const res = await trpcMutation(page, 'organisation.group.update', { + id: groupA.id, + memberIds: [foreignMember.id], + }); + + expect(res.ok()).toBeFalsy(); + + const injectedMembership = await prisma.organisationGroupMember.findFirst({ + where: { groupId: groupA.id, organisationMemberId: foreignMember.id }, + }); + + expect(injectedMembership).toBeNull(); + }); + + test('can add a member from the same organisation to a group (positive control)', async ({ page }) => { + const { user: actor, organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [memberUser] = await seedOrganisationMembers({ + members: [{ name: 'Member', organisationRole: 'MEMBER' }], + organisationId: organisation.id, + }); + + const member = await getOrganisationMember(memberUser.id, organisation.id); + + const group = await createCustomGroup(organisation.id, 'MEMBER'); + + await apiSignin({ page, email: actor.email }); + + const res = await trpcMutation(page, 'organisation.group.update', { + id: group.id, + memberIds: [member.id], + }); + + expect(res.ok()).toBeTruthy(); + + const membership = await prisma.organisationGroupMember.findFirst({ + where: { groupId: group.id, organisationMemberId: member.id }, + }); + + expect(membership).not.toBeNull(); + }); +}); diff --git a/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts b/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts index 48ab68685..336222d93 100644 --- a/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts +++ b/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts @@ -39,7 +39,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => { await page.getByRole('option', { name: 'No' }).click(); await page.getByTestId('include-signing-certificate-trigger').click(); await page.getByRole('option', { name: 'No' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); const teamSettings = await getTeamSettings({ @@ -73,7 +73,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => { await page.getByTestId('document-date-format-trigger').click(); await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible(); const updatedTeamSettings = await getTeamSettings({ @@ -128,7 +128,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => { await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com'); await page.getByRole('textbox', { name: 'Brand Details' }).click(); await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails'); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); const teamSettings = await getTeamSettings({ @@ -150,7 +150,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => { await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com'); await page.getByRole('textbox', { name: 'Brand Details' }).click(); await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails'); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); const updatedTeamSettings = await getTeamSettings({ @@ -165,7 +165,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => { // Test inheritance by setting team back to inherit from organisation await page.getByTestId('enable-branding').click(); await page.getByRole('option', { name: 'Inherit from organisation' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible(); await page.waitForTimeout(2000); @@ -208,7 +208,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => { await page.getByRole('checkbox', { name: 'Email the signer if the document is still pending' }).uncheck(); await page.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' }).uncheck(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); const teamSettings = await getTeamSettings({ @@ -245,7 +245,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => { await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck(); await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); const updatedTeamSettings = await getTeamSettings({ @@ -292,7 +292,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => { await page.getByRole('textbox', { name: 'Reply to email' }).fill(''); await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click(); await page.getByRole('option', { name: 'Inherit from organisation' }).click(); - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible(); await page.waitForTimeout(1000); diff --git a/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts b/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts index 414c18508..6f6ce998c 100644 --- a/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts +++ b/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts @@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test'; import { apiSignin } from '../fixtures/authentication'; import { expectToastTextToBeVisible } from '../fixtures/generic'; +import { signSignaturePad } from '../fixtures/signature'; test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => { const { user, team } = await seedUser(); @@ -73,8 +74,19 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => { await expect(page.locator('body')).toContainText('public-direct-template-title'); await expect(page.locator('body')).toContainText('public-direct-template-description'); + const directSignatureField = directTemplate.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + await page.getByRole('link', { name: 'Sign' }).click(); await page.getByRole('button', { name: 'Continue' }).click(); + + 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 page.getByRole('button', { name: 'Sign' }).click(); diff --git a/packages/app-tests/e2e/recipient/recipient-visibility-access.spec.ts b/packages/app-tests/e2e/recipient/recipient-visibility-access.spec.ts new file mode 100644 index 000000000..50de44307 --- /dev/null +++ b/packages/app-tests/e2e/recipient/recipient-visibility-access.spec.ts @@ -0,0 +1,72 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { hashString } from '@documenso/lib/server-only/auth/hash'; +import { alphaid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { seedCompletedDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { expect, test } from '@playwright/test'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ mode: 'parallel' }); + +const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => { + const token = `api_${alphaid(16)}`; + + await prisma.apiToken.create({ + data: { name: 'recipient-access-test', token: hashString(token), expires: null, userId, teamId }, + }); + + return { token }; +}; + +/** + * Reading a recipient exposes its signing token (a bearer credential), so the + * recipient read must enforce document visibility — a member who cannot read a + * restricted document must not be able to read its recipients either. This + * mirrors the field read, which is asserted as a control below. + */ +test('[RECIPIENT]: a member cannot read a recipient of a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const recipient = await prisma.recipient.findFirstOrThrow({ where: { envelopeId: document.id } }); + + const res = await request.get(`${API_BASE_URL}/envelope/recipient/${recipient.id}`, { + headers: { Authorization: `Bearer ${memberToken}` }, + }); + + expect(res.status()).toBe(404); + + const body = res.ok() ? await res.json() : null; + expect(body?.token).toBeUndefined(); +}); + +test('[RECIPIENT]: a member cannot read a field of a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const field = await prisma.field.findFirst({ where: { envelopeId: document.id } }); + + test.skip(!field, 'No field seeded on completed document'); + + const res = await request.get(`${API_BASE_URL}/envelope/field/${field!.id}`, { + headers: { Authorization: `Bearer ${memberToken}` }, + }); + + expect(res.status()).toBe(404); +}); diff --git a/packages/app-tests/e2e/signing-branding.spec.ts b/packages/app-tests/e2e/signing-branding.spec.ts index 9cd9fbf85..1292d2f83 100644 --- a/packages/app-tests/e2e/signing-branding.spec.ts +++ b/packages/app-tests/e2e/signing-branding.spec.ts @@ -142,3 +142,38 @@ test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Web await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0); await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0); }); + +test('[SIGNING_BRANDING]: custom logo renders when branding is enabled and is hidden when disabled', async ({ + page, +}) => { + const { user, team, organisation } = await seedUser(); + + await enableOrganisationBranding({ + organisationGlobalSettingsId: organisation.organisationGlobalSettingsId, + }); + + const { recipients } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: ['enabled-disabled-branding-signer@test.documenso.com'], + fields: [FieldType.SIGNATURE], + updateDocumentOptions: { internalVersion: 2 }, + }); + + // Branding enabled → the custom logo is rendered on the signing page. + await page.goto(`/sign/${recipients[0].token}`); + await expectPlainBrandingLogo(page, `${team.name}'s Logo`); + + // Disable branding while keeping the stored logo (the team inherits this). + await prisma.organisationGlobalSettings.update({ + where: { id: organisation.organisationGlobalSettingsId }, + data: { brandingEnabled: false }, + }); + + // Branding disabled → the custom logo is gone and the Documenso fallback + // (an internal link to "/") is shown instead. + await page.goto(`/sign/${recipients[0].token}`); + + await expect(page.getByRole('img', { name: `${team.name}'s Logo` })).toHaveCount(0); + await expect(page.locator('a[href="/"]').first()).toBeVisible(); +}); diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts index 293ac0113..5e4892444 100644 --- a/packages/app-tests/e2e/teams/manage-team.spec.ts +++ b/packages/app-tests/e2e/teams/manage-team.spec.ts @@ -66,7 +66,7 @@ test('[TEAMS]: update team', async ({ page }) => { await page.getByLabel('Team URL*').clear(); await page.getByLabel('Team URL*').fill(updatedTeamId); - await page.getByRole('button', { name: 'Update team' }).click(); + await page.getByRole('button', { name: 'Save changes' }).click(); // Check we have been redirected to the new team URL and the name is updated. await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`); diff --git a/packages/app-tests/e2e/teams/team-groups.spec.ts b/packages/app-tests/e2e/teams/team-groups.spec.ts new file mode 100644 index 000000000..74c7f6372 --- /dev/null +++ b/packages/app-tests/e2e/teams/team-groups.spec.ts @@ -0,0 +1,163 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { generateDatabaseId } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, type Page, test } from '@playwright/test'; +import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client'; + +import { apiSignin } from '../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ mode: 'parallel' }); + +/** + * Calls a team-group tRPC mutation directly, bypassing the UI. + * + * The UI only ever surfaces CUSTOM / INTERNAL_ORGANISATION groups, so these + * authorisation rules must be enforced on the server - a crafted request can + * target any `teamGroupId`, including the system-managed INTERNAL_TEAM groups. + */ +const callTeamGroupMutation = ( + page: Page, + procedure: 'team.group.delete' | 'team.group.update', + teamId: number, + input: Record, +) => + page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, { + headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() }, + data: JSON.stringify({ json: input }), + }); + +/** + * Every team is created with three system-managed INTERNAL_TEAM groups + * (admin/manager/member). They are the backbone of team-specific access and, + * like organisation internal groups, must not be deletable - deleting them + * silently strips team members of access while leaving the team row in place. + */ +test('[TEAMS]: internal team groups cannot be deleted via the API', async ({ page }) => { + // Member inheritance OFF: membership is granted exclusively through the team's + // INTERNAL_TEAM groups, so removing them is what causes the access loss. + const { user: owner, team } = await seedUser({ inheritMembers: false }); + + // A direct team member whose access depends on the INTERNAL_TEAM member group. + const directMember = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + await apiSignin({ page, email: owner.email }); + + const internalTeamGroups = await prisma.teamGroup.findMany({ + where: { + teamId: team.id, + organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM }, + }, + }); + + // admin + manager + member. + expect(internalTeamGroups).toHaveLength(3); + + for (const group of internalTeamGroups) { + const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, { + teamId: team.id, + teamGroupId: group.id, + }); + + expect(response.status(), `INTERNAL_TEAM ${group.teamRole} group must not be deletable`).not.toBe(200); + } + + // None of the internal groups were removed. + const remaining = await prisma.teamGroup.count({ + where: { + teamId: team.id, + organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM }, + }, + }); + + expect(remaining).toBe(3); + + // The direct member therefore keeps their team access. + const memberStillHasAccess = await prisma.teamGroup.findFirst({ + where: { + teamId: team.id, + organisationGroup: { + type: OrganisationGroupType.INTERNAL_TEAM, + organisationGroupMembers: { + some: { organisationMember: { userId: directMember.id } }, + }, + }, + }, + }); + + expect(memberStillHasAccess).not.toBeNull(); +}); + +/** + * Guards against over-blocking: user-created (CUSTOM) team groups are not + * internal and must remain removable by team managers/admins. + */ +test('[TEAMS]: custom team groups can still be deleted', async ({ page }) => { + const { user: owner, organisation, team } = await seedUser({ inheritMembers: false }); + + const customGroup = await prisma.organisationGroup.create({ + data: { + id: generateDatabaseId('org_group'), + name: `custom-${team.url}`, + type: OrganisationGroupType.CUSTOM, + organisationRole: OrganisationMemberRole.MEMBER, + organisationId: organisation.id, + teamGroups: { + create: { + id: generateDatabaseId('team_group'), + teamId: team.id, + teamRole: TeamMemberRole.MEMBER, + }, + }, + }, + include: { teamGroups: true }, + }); + + const customTeamGroup = customGroup.teamGroups[0]; + + await apiSignin({ page, email: owner.email }); + + const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, { + teamId: team.id, + teamGroupId: customTeamGroup.id, + }); + + expect(response.status()).toBe(200); + + const deleted = await prisma.teamGroup.findUnique({ where: { id: customTeamGroup.id } }); + + expect(deleted).toBeNull(); +}); + +/** + * The same root cause affects updates: an INTERNAL_TEAM group's role must not be + * editable either, otherwise a team admin could rewrite the backbone roles + * (e.g. promote the member group to admin). + */ +test('[TEAMS]: internal team groups cannot be updated via the API', async ({ page }) => { + const { user: owner, team } = await seedUser({ inheritMembers: false }); + + await apiSignin({ page, email: owner.email }); + + const internalMemberGroup = await prisma.teamGroup.findFirstOrThrow({ + where: { + teamId: team.id, + teamRole: TeamMemberRole.MEMBER, + organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM }, + }, + }); + + const response = await callTeamGroupMutation(page, 'team.group.update', team.id, { + id: internalMemberGroup.id, + data: { teamRole: TeamMemberRole.ADMIN }, + }); + + expect(response.status()).not.toBe(200); + + const reloaded = await prisma.teamGroup.findUniqueOrThrow({ where: { id: internalMemberGroup.id } }); + + expect(reloaded.teamRole).toBe(TeamMemberRole.MEMBER); +}); diff --git a/packages/app-tests/e2e/teams/team-profile-access.spec.ts b/packages/app-tests/e2e/teams/team-profile-access.spec.ts new file mode 100644 index 000000000..ecff18fed --- /dev/null +++ b/packages/app-tests/e2e/teams/team-profile-access.spec.ts @@ -0,0 +1,53 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberRole } from '@documenso/prisma/client'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { expect, test } from '@playwright/test'; + +import { apiSignin } from '../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ mode: 'parallel' }); + +/** + * Editing the team public profile is a team-management action and must require + * MANAGE_TEAM, consistent with renaming the team or changing its URL. + */ +test('[TEAMS]: a member cannot edit the team public profile', async ({ page }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + await apiSignin({ page, email: member.email }); + + const profileRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, { + headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() }, + data: JSON.stringify({ + json: { + teamId: team.id, + data: { profileEnabled: true, profileBio: 'edited-by-member' }, + }, + }), + }); + + expect(profileRes.status()).not.toBe(200); + + const profile = await prisma.teamProfile.findUnique({ where: { teamId: team.id } }); + expect(profile?.enabled ?? false).toBe(false); + expect(profile?.bio ?? '').not.toBe('edited-by-member'); + + // The name/url path of the same route is also management-gated. + const nameRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, { + headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() }, + data: JSON.stringify({ + json: { teamId: team.id, data: { name: 'renamed-by-member' } }, + }), + }); + + expect(nameRes.status()).not.toBe(200); + + const reloaded = await prisma.team.findUnique({ where: { id: team.id } }); + expect(reloaded?.name).not.toBe('renamed-by-member'); + + expect(owner.id).toBeTruthy(); +}); diff --git a/packages/app-tests/e2e/teams/team-settings-save-bar.spec.ts b/packages/app-tests/e2e/teams/team-settings-save-bar.spec.ts new file mode 100644 index 000000000..272b649e7 --- /dev/null +++ b/packages/app-tests/e2e/teams/team-settings-save-bar.spec.ts @@ -0,0 +1,79 @@ +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; + +import { apiSignin } from '../fixtures/authentication'; + +test('[TEAMS]: settings save bar docks at the bottom of the form', async ({ page }) => { + const { user, team } = await seedUser(); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/settings`, + }); + + await expect(page.getByLabel('Team Name*')).toBeVisible(); + + const saveButton = page.getByRole('button', { name: 'Save changes' }); + + // Pristine: the docked Save button is present but disabled; no Undo, no floating notice. + await expect(saveButton).toBeVisible(); + await expect(saveButton).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0); + await expect(page.getByText('You have unsaved changes')).not.toBeVisible(); + + // Make a change → Save enables and Undo appears. + const updatedName = `team-${Date.now()}`; + await page.getByLabel('Team Name*').clear(); + await page.getByLabel('Team Name*').fill(updatedName); + + await expect(saveButton).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Undo' })).toBeVisible(); + + // Undo → value restored, Save disabled again, Undo gone. + await page.getByRole('button', { name: 'Undo' }).click(); + await expect(page.getByLabel('Team Name*')).toHaveValue(team.name); + await expect(saveButton).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0); + + // Change again → Save → success toast, returns to a pristine (disabled) state. + await page.getByLabel('Team Name*').clear(); + await page.getByLabel('Team Name*').fill(updatedName); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + + await expect(page.getByText('Your team has been successfully updated.').first()).toBeVisible(); + await expect(saveButton).toBeDisabled(); +}); + +test('[ORGANISATIONS]: settings save bar floats when the form footer is off-screen', async ({ page }) => { + const { user, organisation } = await seedUser({ + isPersonalOrganisation: false, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/o/${organisation.url}/settings/document`, + }); + + // Wait for the long document-preferences form to load. + await expect(page.getByTestId('document-language-trigger')).toBeVisible(); + + // Pristine: no floating notice even though the footer is below the fold. + await expect(page.getByText('You have unsaved changes')).not.toBeVisible(); + + // Edit a field near the top → the footer is off-screen, so the floating pill appears. + await page.getByTestId('document-language-trigger').click(); + await page.getByRole('option', { name: 'German' }).click(); + + await expect(page.getByText('You have unsaved changes')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible(); + + // Scroll to the footer → the floating pill merges into the docked buttons and the + // notice disappears. + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + + await expect(page.getByText('You have unsaved changes')).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible(); +}); diff --git a/packages/app-tests/e2e/teams/team-signature-settings.spec.ts b/packages/app-tests/e2e/teams/team-signature-settings.spec.ts index 7b4e4541e..078d0cab7 100644 --- a/packages/app-tests/e2e/teams/team-signature-settings.spec.ts +++ b/packages/app-tests/e2e/teams/team-signature-settings.spec.ts @@ -75,7 +75,7 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => { await item.click(); } - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); // Wait for the update to complete await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible(); @@ -140,7 +140,7 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => { await item.click(); } - await page.getByRole('button', { name: 'Update' }).first().click(); + await page.getByRole('button', { name: 'Save changes' }).first().click(); // Wait for finish await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible(); diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts index 69a4c9ad2..fbfa218c5 100644 --- a/packages/app-tests/e2e/templates/direct-templates.spec.ts +++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts @@ -197,7 +197,18 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page }) await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByLabel('Email')).toBeDisabled(); + const directSignatureField = directTemplateWithAuth.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + await page.getByRole('button', { name: 'Continue' }).click(); + + 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 page.getByRole('button', { name: 'Sign' }).click(); @@ -235,6 +246,37 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page }) await page.goto(directTemplatePath); await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible(); + + const directSignatureField = directTemplateWithAuth.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + + // 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(); await expect(page.getByLabel('Your Email')).not.toBeVisible(); @@ -266,6 +308,16 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p await expect(page.getByText('Next Recipient Name')).not.toBeVisible(); + const directSignatureField = template.fields[0]; + + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field to exist'); + } + + 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 page.getByRole('button', { name: 'Sign' }).click(); await page.waitForURL(/\/sign/); @@ -299,19 +351,13 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex }, }); - const directTemplateRecipient = template.recipients[0]; + // The seeded direct template already includes a signature field for the direct recipient. + const directSignatureField = template.fields[0]; - if (!directTemplateRecipient) { - throw new Error('Expected direct template recipient to exist'); + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field 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(); @@ -413,19 +459,13 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex }, }); - const directTemplateRecipient = template.recipients[0]; + // The seeded direct template already includes a signature field for the direct recipient. + const directSignatureField = template.fields[0]; - if (!directTemplateRecipient) { - throw new Error('Expected direct template recipient to exist'); + if (!directSignatureField) { + throw new Error('Expected seeded direct template signature field 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(); @@ -521,3 +561,48 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail); await expectSigningRequestJobForRecipient(updatedSecondRecipient.id); }); + +test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({ + page, +}) => { + const { user, team } = await seedUser(); + + const template = await seedDirectTemplate({ + title: 'V1 invalid direct template', + userId: user.id, + teamId: team.id, + createDirectRecipientSignatureField: false, + }); + + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + + await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible(); + await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible(); + + // The signing flow must not render. + await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible(); +}); + +test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({ + page, +}) => { + const { user, team } = await seedUser(); + + const template = await seedDirectTemplate({ + title: 'V2 invalid direct template', + userId: user.id, + teamId: team.id, + internalVersion: 2, + createDirectRecipientSignatureField: false, + }); + + await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); + + await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible(); + await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible(); + + // The signing flow (PDF canvas) must not render. + await expect(page.locator('.konva-container canvas')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible(); +}); diff --git a/packages/app-tests/e2e/templates/template-use-dialog.spec.ts b/packages/app-tests/e2e/templates/template-use-dialog.spec.ts new file mode 100644 index 000000000..1dbca5f8d --- /dev/null +++ b/packages/app-tests/e2e/templates/template-use-dialog.spec.ts @@ -0,0 +1,101 @@ +import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; +import { prisma } from '@documenso/prisma'; +import { seedTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; +import { DocumentStatus, FieldType } from '@prisma/client'; + +import { apiSignin } from '../fixtures/authentication'; +import { expectToastTextToBeVisible } from '../fixtures/generic'; + +const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: 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: 10, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES, + }, + }); +}; + +test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({ + page, +}) => { + const { user, team } = await seedUser(); + + // seedTemplate creates one SIGNER recipient and no fields. + await seedTemplate({ + title: 'Template missing signature fields', + userId: user.id, + teamId: team.id, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates`, + }); + + await page.getByRole('button', { name: 'Use Template' }).click(); + await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible(); + + // Enable distribution so the document is sent on creation. + await page.locator('#distributeDocument').click(); + await page.getByRole('button', { name: 'Create and send' }).click(); + + await expectToastTextToBeVisible(page, 'Missing signature fields'); + await expectToastTextToBeVisible( + page, + 'The document could not be sent because some signers do not have a signature field', + ); +}); + +test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => { + const { user, team } = await seedUser(); + + const template = await seedTemplate({ + title: 'Template with signature fields', + userId: user.id, + teamId: team.id, + }); + + await seedSignatureFieldForRecipient({ + envelopeId: template.id, + recipientId: template.recipients[0].id, + }); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/templates`, + }); + + await page.getByRole('button', { name: 'Use Template' }).click(); + await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible(); + + await page.locator('#distributeDocument').click(); + await page.getByRole('button', { name: 'Create and send' }).click(); + + await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`)); + + const envelopeId = page.url().split('/').pop()?.split('?')[0]; + + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { id: envelopeId }, + }); + + expect(envelope.status).toBe(DocumentStatus.PENDING); +}); diff --git a/packages/app-tests/package.json b/packages/app-tests/package.json index d1b80069c..7b552080c 100644 --- a/packages/app-tests/package.json +++ b/packages/app-tests/package.json @@ -18,7 +18,7 @@ "@playwright/test": "1.56.1", "@types/node": "^20", "@types/pngjs": "^6.0.5", - "tsx": "^4.20.6", + "tsx": "^4.23.1", "pixelmatch": "^7.1.0", "pngjs": "^7.0.0" }, diff --git a/packages/auth/server/lib/errors/error-codes.ts b/packages/auth/server/lib/errors/error-codes.ts index 858457190..dbce82f09 100644 --- a/packages/auth/server/lib/errors/error-codes.ts +++ b/packages/auth/server/lib/errors/error-codes.ts @@ -17,6 +17,7 @@ export const AuthenticationErrorCode = { // TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET', // TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS', InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE', + SigninDisabled: 'SIGNIN_DISABLED', SignupDisabled: 'SIGNUP_DISABLED', SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL', // IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE', diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index 7cf2735cf..8d890b81a 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -1,6 +1,7 @@ import { isDisposableEmail, isEmailDomainAllowedForSignup, + isSigninEnabledForProvider, isSignupEnabledForProvider, } from '@documenso/lib/constants/auth'; import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email'; @@ -64,6 +65,12 @@ export const emailPasswordRoute = new Hono() .post('/authorize', sValidator('json', ZSignInSchema), async (c) => { const requestMetadata = c.get('requestMetadata'); + if (!isSigninEnabledForProvider('email')) { + throw new AppError(AuthenticationErrorCode.SigninDisabled, { + statusCode: 400, + }); + } + const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json'); const loginLimitResult = await loginRateLimit.check({ @@ -244,6 +251,12 @@ export const emailPasswordRoute = new Hono() const { password, currentPassword } = c.req.valid('json'); const requestMetadata = c.get('requestMetadata'); + if (!isSigninEnabledForProvider('email')) { + throw new AppError(AuthenticationErrorCode.SigninDisabled, { + statusCode: 400, + }); + } + const { session, user } = await getSession(c); await updatePassword({ @@ -346,6 +359,12 @@ export const emailPasswordRoute = new Hono() .post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => { const requestMetadata = c.get('requestMetadata'); + if (!isSigninEnabledForProvider('email')) { + throw new AppError(AuthenticationErrorCode.SigninDisabled, { + statusCode: 400, + }); + } + const { email } = c.req.valid('json'); const forgotLimitResult = await forgotPasswordRateLimit.check({ @@ -377,6 +396,12 @@ export const emailPasswordRoute = new Hono() .post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => { const requestMetadata = c.get('requestMetadata'); + if (!isSigninEnabledForProvider('email')) { + throw new AppError(AuthenticationErrorCode.SigninDisabled, { + statusCode: 400, + }); + } + const { token, password } = c.req.valid('json'); const resetLimitResult = await resetPasswordRateLimit.check({ diff --git a/packages/auth/server/types/email-password.ts b/packages/auth/server/types/email-password.ts index eafaca28c..5303d326c 100644 --- a/packages/auth/server/types/email-password.ts +++ b/packages/auth/server/types/email-password.ts @@ -1,4 +1,4 @@ -import { ZNameSchema } from '@documenso/lib/constants/auth'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { zEmail } from '@documenso/lib/utils/zod'; import { z } from 'zod'; diff --git a/packages/ee/server-only/signing/csc/execute-tsp-sign.ts b/packages/ee/server-only/signing/csc/execute-tsp-sign.ts index 5045b33d8..3c95720f6 100644 --- a/packages/ee/server-only/signing/csc/execute-tsp-sign.ts +++ b/packages/ee/server-only/signing/csc/execute-tsp-sign.ts @@ -1,6 +1,5 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { jobs } from '@documenso/lib/jobs/client'; -import { sendPendingEmail } from '@documenso/lib/server-only/document/send-pending-email'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; @@ -474,9 +473,12 @@ export const executeTspSign = async (opts: ExecuteTspSignOptions): Promise 0) { - await sendPendingEmail({ - id: { type: 'envelopeId', id: envelope.id }, - recipientId: recipient.id, + await jobs.triggerJob({ + name: 'send.document.pending.email', + payload: { + envelopeId: envelope.id, + recipientId: recipient.id, + }, }); // TSP envelopes are forced SEQUENTIAL at send-time; this branch always diff --git a/packages/email/package.json b/packages/email/package.json index bb549f70b..33426f9e5 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -17,7 +17,7 @@ "clean": "rimraf node_modules" }, "dependencies": { - "@documenso/nodemailer-resend": "4.0.0", + "@documenso/nodemailer-resend": "5.0.0", "@documenso/tailwind-config": "*", "@react-email/body": "0.2.0", "@react-email/button": "0.2.0", @@ -38,12 +38,12 @@ "@react-email/section": "0.0.16", "@react-email/tailwind": "^2.0.1", "@react-email/text": "0.1.5", - "nodemailer": "^8.0.5", + "nodemailer": "^9.0.0", "react-email": "^5.0.6", "resend": "^6.5.2" }, "devDependencies": { "@documenso/tsconfig": "*", - "@types/nodemailer": "^8.0.0" + "@types/nodemailer": "^8.0.1" } } diff --git a/packages/email/template-components/template-document-completed.tsx b/packages/email/template-components/template-document-completed.tsx index 7742c90fa..ec453f315 100644 --- a/packages/email/template-components/template-document-completed.tsx +++ b/packages/email/template-components/template-document-completed.tsx @@ -28,7 +28,11 @@ export const TemplateDocumentCompleted = ({
- + Completed @@ -47,7 +51,7 @@ export const TemplateDocumentCompleted = ({ className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline" href={downloadLink} > - + Download
diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx index 0606d6658..2c53070d6 100644 --- a/packages/email/template-components/template-document-invite.tsx +++ b/packages/email/template-components/template-document-invite.tsx @@ -87,7 +87,7 @@ export const TemplateDocumentInvite = ({
@@ -59,7 +67,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline" href="https://documenso.com/pricing" > - + View plans
diff --git a/packages/email/template-components/template-image.tsx b/packages/email/template-components/template-image.tsx index 8f821c10f..b16bcfe60 100644 --- a/packages/email/template-components/template-image.tsx +++ b/packages/email/template-components/template-image.tsx @@ -11,7 +11,7 @@ export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: Template return new URL(path, assetBaseUrl).toString(); }; - return ; + return ; }; export default TemplateImage; diff --git a/packages/email/templates/access-auth-2fa.tsx b/packages/email/templates/access-auth-2fa.tsx index ecff50224..adc66f65a 100644 --- a/packages/email/templates/access-auth-2fa.tsx +++ b/packages/email/templates/access-auth-2fa.tsx @@ -30,9 +30,10 @@ export const AccessAuth2FAEmailTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/admin-user-created.tsx b/packages/email/templates/admin-user-created.tsx index f82ce4605..ccc92020b 100644 --- a/packages/email/templates/admin-user-created.tsx +++ b/packages/email/templates/admin-user-created.tsx @@ -21,8 +21,9 @@ export const AdminUserCreatedTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/bulk-send-complete.tsx b/packages/email/templates/bulk-send-complete.tsx index 8b0bf3b45..cc15ad0c3 100644 --- a/packages/email/templates/bulk-send-complete.tsx +++ b/packages/email/templates/bulk-send-complete.tsx @@ -24,11 +24,14 @@ export const BulkSendCompleteEmail = ({ }: BulkSendCompleteEmailProps) => { const { _ } = useLingui(); + const previewText = msg`Bulk send operation complete for template "${templateName}"`; + return ( - {_(msg`Bulk send operation complete for template "${templateName}"`)} + {_(previewText)} +
diff --git a/packages/email/templates/confirm-email.tsx b/packages/email/templates/confirm-email.tsx index 5024b28b0..f57dcdec9 100644 --- a/packages/email/templates/confirm-email.tsx +++ b/packages/email/templates/confirm-email.tsx @@ -18,8 +18,9 @@ export const ConfirmEmailTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/confirm-team-email.tsx b/packages/email/templates/confirm-team-email.tsx index dfb760d35..6deab57de 100644 --- a/packages/email/templates/confirm-team-email.tsx +++ b/packages/email/templates/confirm-team-email.tsx @@ -30,9 +30,9 @@ export const ConfirmTeamEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-cancel.tsx b/packages/email/templates/document-cancel.tsx index f2eb84c6e..34c077252 100644 --- a/packages/email/templates/document-cancel.tsx +++ b/packages/email/templates/document-cancel.tsx @@ -23,9 +23,10 @@ export const DocumentCancelTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/document-completed.tsx b/packages/email/templates/document-completed.tsx index bc54748aa..b0cff91d3 100644 --- a/packages/email/templates/document-completed.tsx +++ b/packages/email/templates/document-completed.tsx @@ -26,9 +26,9 @@ export const DocumentCompletedEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-created-from-direct-template.tsx b/packages/email/templates/document-created-from-direct-template.tsx index 4ae9aad98..5b8b12abc 100644 --- a/packages/email/templates/document-created-from-direct-template.tsx +++ b/packages/email/templates/document-created-from-direct-template.tsx @@ -33,9 +33,9 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-invite.tsx b/packages/email/templates/document-invite.tsx index 5e8d3a6d4..a876680c6 100644 --- a/packages/email/templates/document-invite.tsx +++ b/packages/email/templates/document-invite.tsx @@ -56,9 +56,10 @@ export const DocumentInviteEmailTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
@@ -85,7 +86,7 @@ export const DocumentInviteEmailTemplate = ({ {inviterName}{' '} - + ({inviterEmail}) diff --git a/packages/email/templates/document-pending.tsx b/packages/email/templates/document-pending.tsx index a6f91b60a..f0879b080 100644 --- a/packages/email/templates/document-pending.tsx +++ b/packages/email/templates/document-pending.tsx @@ -20,9 +20,9 @@ export const DocumentPendingEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-recipient-signed.tsx b/packages/email/templates/document-recipient-signed.tsx index df7fed7b8..af125f628 100644 --- a/packages/email/templates/document-recipient-signed.tsx +++ b/packages/email/templates/document-recipient-signed.tsx @@ -28,9 +28,9 @@ export const DocumentRecipientSignedEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-rejected.tsx b/packages/email/templates/document-rejected.tsx index c17348eba..3e343dbb3 100644 --- a/packages/email/templates/document-rejected.tsx +++ b/packages/email/templates/document-rejected.tsx @@ -28,9 +28,10 @@ export function DocumentRejectedEmail({ return ( - {previewText} + {previewText} +
diff --git a/packages/email/templates/document-rejection-confirmed.tsx b/packages/email/templates/document-rejection-confirmed.tsx index 383417e20..e88e31d59 100644 --- a/packages/email/templates/document-rejection-confirmed.tsx +++ b/packages/email/templates/document-rejection-confirmed.tsx @@ -28,9 +28,10 @@ export function DocumentRejectionConfirmedEmail({ return ( - {previewText} + {previewText} +
diff --git a/packages/email/templates/document-reminder.tsx b/packages/email/templates/document-reminder.tsx index 5633b4959..c5759b48d 100644 --- a/packages/email/templates/document-reminder.tsx +++ b/packages/email/templates/document-reminder.tsx @@ -37,9 +37,10 @@ export const DocumentReminderEmailTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/document-self-signed.tsx b/packages/email/templates/document-self-signed.tsx index 3d8657f2d..7c848ff99 100644 --- a/packages/email/templates/document-self-signed.tsx +++ b/packages/email/templates/document-self-signed.tsx @@ -20,9 +20,9 @@ export const DocumentSelfSignedEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-super-delete.tsx b/packages/email/templates/document-super-delete.tsx index d0098bf7d..03cb0b0a9 100644 --- a/packages/email/templates/document-super-delete.tsx +++ b/packages/email/templates/document-super-delete.tsx @@ -23,9 +23,10 @@ export const DocumentSuperDeleteEmailTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/forgot-password.tsx b/packages/email/templates/forgot-password.tsx index 16652b99e..93620700a 100644 --- a/packages/email/templates/forgot-password.tsx +++ b/packages/email/templates/forgot-password.tsx @@ -20,9 +20,10 @@ export const ForgotPasswordTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/organisation-account-link-confirmation.tsx b/packages/email/templates/organisation-account-link-confirmation.tsx index 2c66b86fa..aca38b491 100644 --- a/packages/email/templates/organisation-account-link-confirmation.tsx +++ b/packages/email/templates/organisation-account-link-confirmation.tsx @@ -30,8 +30,9 @@ export const OrganisationAccountLinkConfirmationTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/organisation-delete.tsx b/packages/email/templates/organisation-delete.tsx index d78a6a211..81937870c 100644 --- a/packages/email/templates/organisation-delete.tsx +++ b/packages/email/templates/organisation-delete.tsx @@ -34,9 +34,9 @@ export const OrganisationDeleteEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/organisation-invite.tsx b/packages/email/templates/organisation-invite.tsx index 14322662b..09961a4d1 100644 --- a/packages/email/templates/organisation-invite.tsx +++ b/packages/email/templates/organisation-invite.tsx @@ -29,9 +29,9 @@ export const OrganisationInviteEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/organisation-join.tsx b/packages/email/templates/organisation-join.tsx index 6356aa090..eb676b5d3 100644 --- a/packages/email/templates/organisation-join.tsx +++ b/packages/email/templates/organisation-join.tsx @@ -31,9 +31,9 @@ export const OrganisationJoinEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/organisation-leave.tsx b/packages/email/templates/organisation-leave.tsx index f2d1b6313..058b830cb 100644 --- a/packages/email/templates/organisation-leave.tsx +++ b/packages/email/templates/organisation-leave.tsx @@ -31,9 +31,9 @@ export const OrganisationLeaveEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/organisation-limit-alert.tsx b/packages/email/templates/organisation-limit-alert.tsx index 536710d29..5cd04001a 100644 --- a/packages/email/templates/organisation-limit-alert.tsx +++ b/packages/email/templates/organisation-limit-alert.tsx @@ -29,9 +29,9 @@ export const OrganisationLimitAlertEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/recipient-expired.tsx b/packages/email/templates/recipient-expired.tsx index 0592089cc..71a186462 100644 --- a/packages/email/templates/recipient-expired.tsx +++ b/packages/email/templates/recipient-expired.tsx @@ -23,9 +23,10 @@ export const RecipientExpiredTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/recipient-removed-from-document.tsx b/packages/email/templates/recipient-removed-from-document.tsx index a495edbb8..5035c9679 100644 --- a/packages/email/templates/recipient-removed-from-document.tsx +++ b/packages/email/templates/recipient-removed-from-document.tsx @@ -22,9 +22,10 @@ export const RecipientRemovedFromDocumentTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/reset-password.tsx b/packages/email/templates/reset-password.tsx index 3c4a67c8f..19c5979a3 100644 --- a/packages/email/templates/reset-password.tsx +++ b/packages/email/templates/reset-password.tsx @@ -22,9 +22,10 @@ export const ResetPasswordTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/team-delete.tsx b/packages/email/templates/team-delete.tsx index 336b46d62..59ca46243 100644 --- a/packages/email/templates/team-delete.tsx +++ b/packages/email/templates/team-delete.tsx @@ -29,9 +29,9 @@ export const TeamDeleteEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/team-email-removed.tsx b/packages/email/templates/team-email-removed.tsx index 0da2b3554..7543c8789 100644 --- a/packages/email/templates/team-email-removed.tsx +++ b/packages/email/templates/team-email-removed.tsx @@ -30,9 +30,9 @@ export const TeamEmailRemovedTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/transports/mailchannels.ts b/packages/email/transports/mailchannels.ts index 93e82804d..923346f79 100644 --- a/packages/email/transports/mailchannels.ts +++ b/packages/email/transports/mailchannels.ts @@ -3,6 +3,8 @@ import type { SentMessageInfo, Transport } from 'nodemailer'; import type { Address } from 'nodemailer/lib/mailer'; import type MailMessage from 'nodemailer/lib/mailer/mail-message'; +import { normalizeMailHeaders } from './normalize-headers'; + const VERSION = '1.0.0'; type NodeMailerAddress = string | Address | Array | undefined; @@ -54,6 +56,7 @@ export class MailChannelsTransport implements Transport { const mailBcc = this.toMailChannelsAddresses(mail.data.bcc); const [from] = this.toMailChannelsAddresses(mail.data.from); + const [replyTo] = this.toMailChannelsAddresses(mail.data.replyTo); if (!from) { return callback(new Error('Missing required field "from"'), null); @@ -72,6 +75,8 @@ export class MailChannelsTransport implements Transport { headers: requestHeaders, body: JSON.stringify({ from: from, + reply_to: replyTo, + headers: normalizeMailHeaders(mail.data.headers), subject: mail.data.subject, personalizations: [ { diff --git a/packages/email/transports/normalize-headers.ts b/packages/email/transports/normalize-headers.ts new file mode 100644 index 000000000..4dca6c8f1 --- /dev/null +++ b/packages/email/transports/normalize-headers.ts @@ -0,0 +1,55 @@ +import type Mail from 'nodemailer/lib/mailer'; + +/** + * Normalizes nodemailer mail headers into the flat `Record` + * shape accepted by HTTP email APIs such as Resend and MailChannels. + * + * Kept in sync with `toResendHeaders` in the `@documenso/nodemailer-resend` + * package, which applies the same normalization for the Resend transport. + */ +export const normalizeMailHeaders = (headers: Mail.Options['headers']): Record | undefined => { + if (!headers) { + return undefined; + } + + const normalized: Record = {}; + + const appendHeader = (key: string, value: unknown) => { + if (value === null || value === undefined) { + return; + } + + const stringValue = String(value); + + normalized[key] = normalized[key] ? `${normalized[key]}, ${stringValue}` : stringValue; + }; + + if (Array.isArray(headers)) { + for (const { key, value } of headers) { + appendHeader(key, value); + } + } else { + for (const [key, value] of Object.entries(headers)) { + if (Array.isArray(value)) { + for (const item of value) { + appendHeader(key, item); + } + + continue; + } + + if (typeof value === 'object' && value !== null) { + appendHeader(key, value.value); + continue; + } + + appendHeader(key, value); + } + } + + if (Object.keys(normalized).length === 0) { + return undefined; + } + + return normalized; +}; diff --git a/packages/lib/client-only/hooks/use-envelope-autosave.ts b/packages/lib/client-only/hooks/use-envelope-autosave.ts index 1ca70c28c..93cc8a162 100644 --- a/packages/lib/client-only/hooks/use-envelope-autosave.ts +++ b/packages/lib/client-only/hooks/use-envelope-autosave.ts @@ -1,84 +1,100 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +/** + * Debounced autosave for the envelope editor (recipients, fields, settings). + * + * Only one save runs at a time and the latest edit always wins. If the user + * keeps editing while a save is on the wire, their newest changes get saved + * right after, never dropped. + */ export function useEnvelopeAutosave(saveFn: (data: T) => Promise, delay = 1000) { const timeoutRef = useRef | null>(null); - const lastArgsRef = useRef(null); - const pendingPromiseRef = useRef | null>(null); + + // The edit waiting to be saved. Wrapped in an object so null always means "nothing queued". + const pendingRef = useRef<{ value: T } | null>(null); + + // The save currently running, if any. Shared so we never kick off two at once. + const commitPromiseRef = useRef | null>(null); + + // saveFn closes over editor state, so keep the latest one around without + // making triggerSave/flush depend on it. + const saveFnRef = useRef(saveFn); + saveFnRef.current = saveFn; const [isPending, setIsPending] = useState(false); const [isCommiting, setIsCommiting] = useState(false); + /** + * Runs saves one at a time until the queue is empty. Anything queued + * mid-save gets picked up on the next loop. + */ + const commit = useCallback((): Promise => { + if (commitPromiseRef.current) { + return commitPromiseRef.current; + } + + if (!pendingRef.current) { + return Promise.resolve(); + } + + const pump = (async () => { + try { + setIsCommiting(true); + + while (pendingRef.current) { + const { value } = pendingRef.current; + pendingRef.current = null; + + await saveFnRef.current(value); + } + } finally { + // eslint-disable-next-line require-atomic-updates + commitPromiseRef.current = null; + setIsCommiting(false); + setIsPending(false); + } + })(); + + commitPromiseRef.current = pump; + + return pump; + }, []); + const triggerSave = useCallback( (data: T) => { - lastArgsRef.current = data; + pendingRef.current = { value: data }; - // A debounce or promise means something is pending setIsPending(true); if (timeoutRef.current) { clearTimeout(timeoutRef.current); } - // eslint-disable-next-line @typescript-eslint/no-misused-promises - timeoutRef.current = setTimeout(async () => { - if (!lastArgsRef.current) { - return; - } - - const args = lastArgsRef.current; - lastArgsRef.current = null; + timeoutRef.current = setTimeout(() => { timeoutRef.current = null; - - setIsCommiting(true); - pendingPromiseRef.current = saveFn(args); - - try { - await pendingPromiseRef.current; - } finally { - // eslint-disable-next-line require-atomic-updates - pendingPromiseRef.current = null; - setIsCommiting(false); - setIsPending(false); - } + void commit(); }, delay); }, - [saveFn, delay], + [commit, delay], ); + /** + * Skip the debounce and save now. The editor calls this when it needs + * everything persisted, e.g. before sending or switching steps. + */ const flush = useCallback(async () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } - if (pendingPromiseRef.current) { - // Already running → wait for it - await pendingPromiseRef.current; - return; - } - - if (lastArgsRef.current) { - const args = lastArgsRef.current; - lastArgsRef.current = null; - - setIsCommiting(true); - setIsPending(true); - - pendingPromiseRef.current = saveFn(args); - try { - await pendingPromiseRef.current; - } finally { - // eslint-disable-next-line require-atomic-updates - pendingPromiseRef.current = null; - setIsCommiting(false); - setIsPending(false); - } - } - }, [saveFn]); + await commit(); + }, [commit]); + // Last-ditch attempt to save if the tab closes with unsaved edits. useEffect(() => { const handleBeforeUnload = () => { - if (timeoutRef.current || pendingPromiseRef.current) { + if (timeoutRef.current || pendingRef.current || commitPromiseRef.current) { void flush(); } }; diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 4768540d4..cb514979b 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -1,25 +1,10 @@ import MailChecker from 'mailchecker'; -import { z } from 'zod'; import { env } from '../utils/env'; import { NEXT_PUBLIC_WEBAPP_URL } from './app'; export const SALT_ROUNDS = 12; -export const URL_PATTERN = /https?:\/\/|www\./i; - -/** - * Shared name schema that disallows URLs to prevent phishing via email rendering. - */ -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.', - }); - export const IDENTITY_PROVIDER_NAME: Record = { DOCUMENSO: 'Documenso', GOOGLE: 'Google', @@ -41,6 +26,14 @@ export const IS_OIDC_SSO_ENABLED = Boolean( export const OIDC_PROVIDER_LABEL = env('NEXT_PRIVATE_OIDC_PROVIDER_LABEL'); +/** + * Opt-out flag for the automatic OIDC redirect. + * + * When OIDC is the only enabled signin transport we redirect to the provider + * automatically. Set this to "true" to keep rendering the signin page instead. + */ +export const IS_OIDC_AUTO_REDIRECT_DISABLED = env('NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT') === 'true'; + export const USER_SECURITY_AUDIT_LOG_MAP: Record = { ACCOUNT_SSO_LINK: 'Linked account to SSO', ACCOUNT_SSO_UNLINK: 'Unlinked account from SSO', @@ -188,3 +181,22 @@ export const isSignupEnabledForProvider = (provider: 'email' | 'google' | 'micro return env(flagMap[provider]) !== 'true'; }; + +/** + * Check if signin is enabled for the given provider. + * The master switch takes precedence over the per-provider flags. + */ +export const isSigninEnabledForProvider = (provider: 'email' | 'google' | 'microsoft' | 'oidc'): boolean => { + if (env('NEXT_PUBLIC_DISABLE_SIGNIN') === 'true') { + return false; + } + + const flagMap = { + email: 'NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN', + google: 'NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN', + microsoft: 'NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN', + oidc: 'NEXT_PUBLIC_DISABLE_OIDC_SIGNIN', + } as const; + + return env(flagMap[provider]) !== 'true'; +}; diff --git a/packages/lib/constants/branding.ts b/packages/lib/constants/branding.ts index f50cc7098..0f00237b7 100644 --- a/packages/lib/constants/branding.ts +++ b/packages/lib/constants/branding.ts @@ -9,3 +9,13 @@ * cap so a malicious or runaway payload can't exhaust PostCSS/server memory. */ export const BRANDING_CSS_MAX_LENGTH = 256 * 1024; + +/** + * Branding logo upload constraints. Enforced server-side at the TRPC request + * boundary (`zfdBrandingImageFile`) and reused by the client form for matching UX. + */ +export const BRANDING_LOGO_MAX_SIZE_MB = 5; + +export const BRANDING_LOGO_MAX_SIZE_BYTES = BRANDING_LOGO_MAX_SIZE_MB * 1024 * 1024; + +export const BRANDING_LOGO_ALLOWED_TYPES: string[] = ['image/jpeg', 'image/png', 'image/webp']; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index 75338e0de..5dc3e7b7e 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -36,6 +36,13 @@ export enum AppErrorCode { */ ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED', + /** + * A signer recipient does not have a signature field assigned. Thrown when + * distributing an envelope or using a direct template where at least one + * signer has no signature field. + */ + MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD', + /** * CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec * for the recovery taxonomy. @@ -84,6 +91,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record { + const { teamId, documentName, inviterName, inviterEmail, meta, recipients } = payload; + + if (recipients.length === 0) { + return; + } + + const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId, + }, + meta, + }); + + // Don't send cancellation emails if the organisation has email sending + // disabled. Re-checked here (not just at enqueue time) because the org can be + // disabled between the delete request and this job running. + if (emailsDisabled) { + return; + } + + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + const i18n = await getI18nInstance(emailLanguage); + + for (const recipient of recipients) { + await io.runTask(`send-document-deleted-emails-${recipient.email}`, async () => { + if (!isRecipientEmailValidForSending(recipient)) { + return; + } + + const template = createElement(DocumentCancelTemplate, { + documentName, + inviterName: inviterName || undefined, + inviterEmail, + assetBaseUrl, + }); + + const [html, text] = await Promise.all([ + renderEmailWithI18N(template, { lang: emailLanguage, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), + ]); + + await emailTransport.sendMail({ + to: { + address: recipient.email, + name: recipient.name, + }, + from: senderEmail, + replyTo: replyToEmail, + subject: i18n._(msg`Document Cancelled`), + html, + text, + }); + }); + } +}; diff --git a/packages/lib/jobs/definitions/emails/send-document-deleted-emails.ts b/packages/lib/jobs/definitions/emails/send-document-deleted-emails.ts new file mode 100644 index 000000000..e63de8f6e --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-document-deleted-emails.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID = 'send.document.deleted.emails'; + +const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA = z.object({ + teamId: z.number(), + documentName: z.string(), + inviterName: z.string().optional(), + inviterEmail: z.string(), + /** + * The document's email meta (sender, reply-to, language). Captured before the + * envelope is hard-deleted so `getEmailContext` resolves the exact same + * sender/reply-to/language the inline send used. + */ + meta: z + .object({ + emailId: z.string().nullable().optional(), + emailReplyTo: z.string().nullable().optional(), + language: z.string().optional(), + }) + .nullable(), + recipients: z + .object({ + email: z.string(), + name: z.string(), + }) + .array(), +}); + +export type TSendDocumentDeletedEmailsJobDefinition = z.infer< + typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA +>; + +export const SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION = { + id: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID, + name: 'Send Document Deleted Emails', + version: '1.0.0', + trigger: { + name: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID, + schema: SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./send-document-deleted-emails.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION_ID, + TSendDocumentDeletedEmailsJobDefinition +>; diff --git a/packages/lib/server-only/document/send-pending-email.ts b/packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts similarity index 66% rename from packages/lib/server-only/document/send-pending-email.ts rename to packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts index 4d803f5dd..3a7bf3e96 100644 --- a/packages/lib/server-only/document/send-pending-email.ts +++ b/packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts @@ -1,27 +1,24 @@ import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending'; +import { unsafeBuildEnvelopeIdQuery } from '@documenso/lib/utils/envelope'; import { prisma } from '@documenso/prisma'; import { msg } from '@lingui/core/macro'; import { 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 { getEmailContext } from '../../../server-only/email/get-email-context'; +import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; +import { isRecipientEmailValidForSending } from '../../../utils/recipients'; +import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TSendDocumentPendingEmailJobDefinition } from './send-document-pending-email'; -import { getI18nInstance } from '../../client-only/providers/i18n-server'; -import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; -import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; -import type { EnvelopeIdOptions } from '../../utils/envelope'; -import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; -import { isRecipientEmailValidForSending } from '../../utils/recipients'; -import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; -import { getEmailContext } from '../email/get-email-context'; +export const run = async ({ payload }: { payload: TSendDocumentPendingEmailJobDefinition; io: JobRunIO }) => { + const { envelopeId, recipientId } = payload; -export interface SendPendingEmailOptions { - id: EnvelopeIdOptions; - recipientId: number; -} - -export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOptions) => { const envelope = await prisma.envelope.findFirst({ where: { - ...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT), + ...unsafeBuildEnvelopeIdQuery({ type: 'envelopeId', id: envelopeId }, EnvelopeType.DOCUMENT), recipients: { some: { id: recipientId, @@ -38,12 +35,8 @@ export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOpti }, }); - if (!envelope) { - throw new Error('Document not found'); - } - - if (envelope.recipients.length === 0) { - throw new Error('Document has no recipients'); + if (!envelope || envelope.recipients.length === 0) { + return; } const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({ diff --git a/packages/lib/jobs/definitions/emails/send-document-pending-email.ts b/packages/lib/jobs/definitions/emails/send-document-pending-email.ts new file mode 100644 index 000000000..ff6b885c7 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-document-pending-email.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID = 'send.document.pending.email'; + +const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ + envelopeId: z.string(), + recipientId: z.number(), +}); + +export type TSendDocumentPendingEmailJobDefinition = z.infer; + +export const SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION = { + id: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID, + name: 'Send Document Pending Email', + version: '1.0.0', + trigger: { + name: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID, + schema: SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./send-document-pending-email.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION_ID, + TSendDocumentPendingEmailJobDefinition +>; diff --git a/packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts b/packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts new file mode 100644 index 000000000..8f402704e --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts @@ -0,0 +1,105 @@ +import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document'; +import { prisma } from '@documenso/prisma'; +import { msg } from '@lingui/core/macro'; +import { createElement } from 'react'; + +import { getI18nInstance } from '../../../client-only/providers/i18n-server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; +import { getEmailContext } from '../../../server-only/email/get-email-context'; +import { assertOrganisationRatesAndLimits } from '../../../server-only/rate-limit/assert-organisation-rates-and-limits'; +import { extractDerivedDocumentEmailSettings } from '../../../types/document-email'; +import { isRecipientEmailValidForSending } from '../../../utils/recipients'; +import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TSendRecipientRemovedEmailJobDefinition } from './send-recipient-removed-email'; + +export const run = async ({ payload, io }: { payload: TSendRecipientRemovedEmailJobDefinition; io: JobRunIO }) => { + const { envelopeId, recipientEmail, recipientName, inviterName } = payload; + + const envelope = await prisma.envelope.findFirst({ + where: { + id: envelopeId, + }, + include: { + documentMeta: true, + }, + }); + + // The envelope may have been deleted between the recipient removal and this + // job running. Treat as a no-op so the job doesn't retry forever. + if (!envelope || !recipientEmail || !isRecipientEmailValidForSending({ email: recipientEmail })) { + return; + } + + // Re-checked at send time (not just at enqueue) so the email honors the + // document's current "recipient removed" setting. + const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved; + + if (!isRecipientRemovedEmailEnabled) { + return; + } + + const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } = + await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: envelope.teamId, + }, + meta: envelope.documentMeta, + }); + + // Don't send the removal email if the organisation has email sending disabled. + if (emailsDisabled) { + return; + } + + // Meter the removal email against the organisation email quota/stats. + // Add/remove churn can be used to blast unsolicited removal emails outside + // the email limits. + try { + await assertOrganisationRatesAndLimits({ + organisationId, + organisationClaim: claims, + type: 'email', + count: 1, + }); + } catch (_err) { + io.logger.warn({ + msg: 'Recipient removed email dropped: org email limit exceeded', + organisationId, + envelopeId: envelope.id, + }); + + return; + } + + const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; + + const template = createElement(RecipientRemovedFromDocumentTemplate, { + documentName: envelope.title, + inviterName: inviterName || undefined, + assetBaseUrl, + }); + + const i18n = await getI18nInstance(emailLanguage); + + await io.runTask('send-recipient-removed-email', async () => { + const [html, text] = await Promise.all([ + renderEmailWithI18N(template, { lang: emailLanguage, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), + ]); + + await emailTransport.sendMail({ + to: { + address: recipientEmail, + name: recipientName, + }, + from: senderEmail, + replyTo: replyToEmail, + subject: i18n._(msg`You have been removed from a document`), + html, + text, + }); + }); +}; diff --git a/packages/lib/jobs/definitions/emails/send-recipient-removed-email.ts b/packages/lib/jobs/definitions/emails/send-recipient-removed-email.ts new file mode 100644 index 000000000..484c70ac0 --- /dev/null +++ b/packages/lib/jobs/definitions/emails/send-recipient-removed-email.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID = 'send.recipient.removed.email'; + +const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({ + envelopeId: z.string(), + recipientEmail: z.string(), + recipientName: z.string(), + inviterName: z.string().optional(), +}); + +export type TSendRecipientRemovedEmailJobDefinition = z.infer< + typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA +>; + +export const SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION = { + id: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID, + name: 'Send Recipient Removed Email', + version: '1.0.0', + trigger: { + name: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID, + schema: SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./send-recipient-removed-email.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SEND_RECIPIENT_REMOVED_EMAIL_JOB_DEFINITION_ID, + TSendRecipientRemovedEmailJobDefinition +>; diff --git a/packages/lib/package.json b/packages/lib/package.json index 450f39172..41b1f1815 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -60,8 +60,8 @@ "pino": "^9.14.0", "pino-pretty": "^13.1.2", "playwright": "1.56.1", - "postcss": "^8.5.14", - "postcss-selector-parser": "^7.1.1", + "postcss": "^8.5.19", + "postcss-selector-parser": "^7.1.4", "posthog-js": "^1.297.2", "posthog-node": "4.18.0", "react": "^18", diff --git a/packages/lib/server-only/branding/store-branding-logo.ts b/packages/lib/server-only/branding/store-branding-logo.ts new file mode 100644 index 000000000..af0601aec --- /dev/null +++ b/packages/lib/server-only/branding/store-branding-logo.ts @@ -0,0 +1,26 @@ +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { putFileServerSide } from '../../universal/upload/put-file.server'; +import { optimiseBrandingLogo } from '../../utils/images/logo'; + +/** + * Validate, sanitise and store an uploaded branding logo. Returns the + * `JSON.stringify({ type, data })` reference persisted in the `brandingLogo` + * column (the same format the serving endpoints already expect). + */ +export const buildBrandingLogoData = async (file: File): Promise => { + const buffer = Buffer.from(await file.arrayBuffer()); + + const optimised = await optimiseBrandingLogo(buffer).catch(() => { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'The branding logo must be a valid image file.', + }); + }); + + const documentData = await putFileServerSide({ + name: 'branding-logo.png', + type: 'image/png', + arrayBuffer: async () => Promise.resolve(optimised), + }); + + return JSON.stringify(documentData); +}; diff --git a/packages/lib/server-only/document/cancel-document.ts b/packages/lib/server-only/document/cancel-document.ts index 3ac41ca6e..6496e0908 100644 --- a/packages/lib/server-only/document/cancel-document.ts +++ b/packages/lib/server-only/document/cancel-document.ts @@ -8,8 +8,9 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../ import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import type { EnvelopeIdOptions } from '../../utils/envelope'; -import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; +import { mapSecondaryIdToDocumentId } from '../../utils/envelope'; import { isMemberManagerOrAbove } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getMemberRoles } from '../team/get-member-roles'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -22,9 +23,18 @@ export type CancelDocumentOptions = { }; export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => { - // Note: This is an unsafe request, we validate the ownership/permission later in the function. - const envelope = await prisma.envelope.findUnique({ - where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT), + // Resolve the envelope through the visibility-aware helper so the caller must + // have read access (ownership OR team membership with sufficient visibility OR + // team-email). This prevents cancelling a document the caller cannot see. + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id, + userId, + teamId, + type: EnvelopeType.DOCUMENT, + }); + + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, include: { recipients: true, documentMeta: true, @@ -49,16 +59,6 @@ export const cancelDocument = async ({ id, userId, teamId, reason, requestMetada .then((roles) => roles.teamRole) .catch(() => null); - const isUserTeamMember = teamRole !== null; - - // Callers with no relationship to the document must not be able to determine - // whether it exists, so respond as if it was not found. - if (!isUserOwner && !isUserTeamMember) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'Document not found', - }); - } - const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole); // The document is visible to the caller, but cancelling requires elevated permissions. diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index 531cc59f4..10aefd189 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -29,7 +29,6 @@ import { assertRecipientNotExpired } from '../../utils/recipients'; import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { isRecipientAuthorized } from './is-recipient-authorized'; -import { sendPendingEmail } from './send-pending-email'; export type CompleteDocumentWithTokenOptions = { token: string; @@ -389,7 +388,13 @@ export const completeDocumentWithToken = async ({ }); if (pendingRecipients.length > 0) { - await sendPendingEmail({ id, recipientId: recipient.id }); + await jobs.triggerJob({ + name: 'send.document.pending.email', + payload: { + envelopeId: envelope.id, + recipientId: recipient.id, + }, + }); if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) { const [nextRecipient] = pendingRecipients; diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 3711921c6..8dd0b69de 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -1,13 +1,9 @@ -import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; import { prisma } from '@documenso/prisma'; -import { msg } from '@lingui/core/macro'; import type { DocumentMeta, Envelope, Recipient, User } from '@prisma/client'; import { DocumentStatus, EnvelopeType, RecipientRole, SendStatus, WebhookTriggerEvents } from '@prisma/client'; -import { createElement } from 'react'; -import { getI18nInstance } from '../../client-only/providers/i18n-server'; -import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { AppError, AppErrorCode } from '../../errors/app-error'; +import { jobs } from '../../jobs/client'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload'; @@ -16,9 +12,8 @@ import { isDocumentCompleted } from '../../utils/document'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; import { isRecipientEmailValidForSending } from '../../utils/recipients'; -import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { getEmailContext } from '../email/get-email-context'; -import { getMemberRoles } from '../team/get-member-roles'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type DeleteDocumentOptions = { @@ -41,7 +36,9 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De }); } - // Note: This is an unsafe request, we validate the ownership later in the function. + // Note: This is an unsafe request. It is used purely to resolve the recipient + // self-hide path below. The authoritative delete authorization is performed + // via the visibility-aware `getEnvelopeWhereInput` helper. const envelope = await prisma.envelope.findUnique({ where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT), include: { @@ -56,36 +53,47 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De }); } - const isUserTeamMember = await getMemberRoles({ - teamId: envelope.teamId, - reference: { - type: 'User', - id: userId, - }, + // Determine whether the user has authorized delete access using the + // visibility-aware helper. This enforces ownership OR (team membership AND + // the document's visibility is permitted for the member's role) OR team-email + // access. A bare team member without sufficient visibility will resolve to + // null here and therefore must not be able to delete the document. + const hasDeleteAccess = await getEnvelopeWhereInput({ + id, + userId, + teamId, + type: EnvelopeType.DOCUMENT, }) - .then(() => true) + .then(({ envelopeWhereInput }) => + prisma.envelope.findFirst({ + where: envelopeWhereInput, + select: { id: true }, + }), + ) + .then((result) => Boolean(result)) .catch(() => false); - const isUserOwner = envelope.userId === userId; const userRecipient = envelope.recipients.find((recipient) => recipient.email === user.email); - if (!isUserOwner && !isUserTeamMember && !userRecipient) { + if (!hasDeleteAccess && !userRecipient) { throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Not allowed', }); } // Handle hard or soft deleting the actual document if user has permission. - if (isUserOwner || isUserTeamMember) { - await handleDocumentOwnerDelete({ + if (hasDeleteAccess) { + const updatedEnvelope = await handleDocumentOwnerDelete({ envelope, user, requestMetadata, }); + const envelopeForWebhook = { ...envelope, ...(updatedEnvelope ?? {}) }; + await triggerWebhook({ event: WebhookTriggerEvents.DOCUMENT_CANCELLED, - data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)), + data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeForWebhook)), userId, teamId, }); @@ -125,7 +133,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha return; } - const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({ + const { emailLanguage, emailsDisabled } = await getEmailContext({ emailType: 'RECIPIENT', source: { type: 'team', @@ -192,50 +200,40 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha return deletedEnvelope; } - // Send cancellation emails to recipients. - await Promise.all( - envelope.recipients.map(async (recipient) => { - if ( - recipient.sendStatus !== SendStatus.SENT || - !isRecipientEmailValidForSending(recipient) || - recipient.role === RecipientRole.CC - ) { - return; - } + // Enqueue cancellation emails as a background job. The envelope (and its + // documentMeta) is hard-deleted above, so the job can't look it up later — + // pass a self-contained payload with the recipients to notify. + const recipientsToNotify = envelope.recipients + .filter( + (recipient) => + recipient.sendStatus === SendStatus.SENT && + recipient.role !== RecipientRole.CC && + isRecipientEmailValidForSending(recipient), + ) + .map((recipient) => ({ + email: recipient.email, + name: recipient.name, + })); - const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - - const template = createElement(DocumentCancelTemplate, { + if (recipientsToNotify.length > 0) { + await jobs.triggerJob({ + name: 'send.document.deleted.emails', + payload: { + teamId: envelope.teamId, documentName: envelope.title, inviterName: user.name || undefined, inviterEmail: user.email, - assetBaseUrl, - }); - - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang: emailLanguage, branding }), - renderEmailWithI18N(template, { - lang: emailLanguage, - branding, - plainText: true, - }), - ]); - - const i18n = await getI18nInstance(emailLanguage); - - await emailTransport.sendMail({ - to: { - address: recipient.email, - name: recipient.name, - }, - from: senderEmail, - replyTo: replyToEmail, - subject: i18n._(msg`Document Cancelled`), - html, - text, - }); - }), - ); + meta: envelope.documentMeta + ? { + emailId: envelope.documentMeta.emailId, + emailReplyTo: envelope.documentMeta.emailReplyTo, + language: emailLanguage, + } + : null, + recipients: recipientsToNotify, + }, + }); + } return deletedEnvelope; }; diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index 84821e2d5..1715739f7 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad .map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`)) .join(', '); - throw new AppError(AppErrorCode.INVALID_REQUEST, { + throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, { message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`, }); } diff --git a/packages/lib/server-only/envelope-attachment/create-attachment.ts b/packages/lib/server-only/envelope-attachment/create-attachment.ts index b753378cf..0e16639f0 100644 --- a/packages/lib/server-only/envelope-attachment/create-attachment.ts +++ b/packages/lib/server-only/envelope-attachment/create-attachment.ts @@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@prisma/client'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type CreateAttachmentOptions = { envelopeId: string; @@ -15,11 +15,15 @@ export type CreateAttachmentOptions = { }; export const createAttachment = async ({ envelopeId, teamId, userId, data }: CreateAttachmentOptions) => { + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: envelopeId }, + userId, + teamId, + type: null, + }); + const envelope = await prisma.envelope.findFirst({ - where: { - id: envelopeId, - team: buildTeamWhereQuery({ teamId, userId }), - }, + where: envelopeWhereInput, }); if (!envelope) { diff --git a/packages/lib/server-only/envelope-attachment/delete-attachment.ts b/packages/lib/server-only/envelope-attachment/delete-attachment.ts index 67f4e7974..f04e6cbb9 100644 --- a/packages/lib/server-only/envelope-attachment/delete-attachment.ts +++ b/packages/lib/server-only/envelope-attachment/delete-attachment.ts @@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@prisma/client'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type DeleteAttachmentOptions = { id: string; @@ -14,9 +14,6 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO const attachment = await prisma.envelopeAttachment.findFirst({ where: { id, - envelope: { - team: buildTeamWhereQuery({ teamId, userId }), - }, }, include: { envelope: true, @@ -29,6 +26,24 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: attachment.envelopeId }, + userId, + teamId, + type: null, + }); + + // Additional validation to check the user has visibility-aware access to the envelope. + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Attachment not found', + }); + } + if ( attachment.envelope.status === DocumentStatus.COMPLETED || attachment.envelope.status === DocumentStatus.REJECTED diff --git a/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts b/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts index ff6402fea..1e8b1d25e 100644 --- a/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts +++ b/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts @@ -1,7 +1,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type FindAttachmentsByEnvelopeIdOptions = { envelopeId: string; @@ -14,11 +14,15 @@ export const findAttachmentsByEnvelopeId = async ({ userId, teamId, }: FindAttachmentsByEnvelopeIdOptions) => { + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: envelopeId }, + userId, + teamId, + type: null, + }); + const envelope = await prisma.envelope.findFirst({ - where: { - id: envelopeId, - team: buildTeamWhereQuery({ teamId, userId }), - }, + where: envelopeWhereInput, }); if (!envelope) { diff --git a/packages/lib/server-only/envelope-attachment/update-attachment.ts b/packages/lib/server-only/envelope-attachment/update-attachment.ts index c07e902a9..1758c2b60 100644 --- a/packages/lib/server-only/envelope-attachment/update-attachment.ts +++ b/packages/lib/server-only/envelope-attachment/update-attachment.ts @@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@prisma/client'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type UpdateAttachmentOptions = { id: string; @@ -15,9 +15,6 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac const attachment = await prisma.envelopeAttachment.findFirst({ where: { id, - envelope: { - team: buildTeamWhereQuery({ teamId, userId }), - }, }, include: { envelope: true, @@ -30,6 +27,24 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: attachment.envelopeId }, + userId, + teamId, + type: null, + }); + + // Additional validation to check the user has visibility-aware access to the envelope. + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Attachment not found', + }); + } + if ( attachment.envelope.status === DocumentStatus.COMPLETED || attachment.envelope.status === DocumentStatus.REJECTED diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 4ca73a387..2bbb079fd 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -37,9 +37,9 @@ import { extractDerivedDocumentMeta } from '../../utils/document'; import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth'; import { buildTeamWhereQuery } from '../../utils/teams'; import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id'; +import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; import { resolveSignatureLevel } from '../signature-level/resolve-signature-level'; -import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; import { getTeamSettings } from '../team/get-team-settings'; import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; diff --git a/packages/lib/server-only/envelope/duplicate-envelope.ts b/packages/lib/server-only/envelope/duplicate-envelope.ts index a904bb3f2..4770814f8 100644 --- a/packages/lib/server-only/envelope/duplicate-envelope.ts +++ b/packages/lib/server-only/envelope/duplicate-envelope.ts @@ -10,8 +10,8 @@ import { nanoid, prefixedId } from '../../universal/id'; import type { EnvelopeIdOptions } from '../../utils/envelope'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id'; -import { resolveSignatureLevel } from '../signature-level/resolve-signature-level'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; +import { resolveSignatureLevel } from '../signature-level/resolve-signature-level'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export interface DuplicateEnvelopeOptions { diff --git a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts index e855f4fbe..6383bc7f7 100644 --- a/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts +++ b/packages/lib/server-only/envelope/get-envelope-for-direct-template-signing.ts @@ -5,6 +5,7 @@ import { match } from 'ts-pattern'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth'; import { extractDocumentAuthMethods } from '../../utils/document-auth'; +import { getRecipientsWithMissingFields } from '../../utils/recipients'; import { extractFieldAutoInsertValues } from '../document/send-document'; import { getTeamSettings } from '../team/get-team-settings'; import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing'; @@ -125,6 +126,17 @@ export const getEnvelopeForDirectTemplateSigning = async ({ }); } + const recipientsWithMissingFields = getRecipientsWithMissingFields( + envelope.recipients, + envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields), + ); + + if (recipientsWithMissingFields.length > 0) { + throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, { + message: 'One or more signers on this direct template are missing a signature field', + }); + } + const settings = await getTeamSettings({ teamId: envelope.teamId }); const sender = settings.includeSenderDetails diff --git a/packages/lib/server-only/public-api/get-user-by-token.ts b/packages/lib/server-only/public-api/get-user-by-token.ts deleted file mode 100644 index 887730b5c..000000000 --- a/packages/lib/server-only/public-api/get-user-by-token.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { prisma } from '@documenso/prisma'; - -import { AppError, AppErrorCode } from '../../errors/app-error'; -import { hashString } from '../auth/hash'; - -export const getUserByApiToken = async ({ token }: { token: string }) => { - const hashedToken = hashString(token); - - const user = await prisma.user.findFirst({ - where: { - apiTokens: { - some: { - token: hashedToken, - }, - }, - }, - include: { - apiTokens: true, - }, - }); - - if (!user) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'Invalid token', - statusCode: 401, - }); - } - - const retrievedToken = user.apiTokens.find((apiToken) => apiToken.token === hashedToken); - - // This should be impossible but we need to satisfy TypeScript - if (!retrievedToken) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'Invalid token', - statusCode: 401, - }); - } - - if (retrievedToken.expires && retrievedToken.expires < new Date()) { - throw new Error('Expired token'); - } - - return user; -}; diff --git a/packages/lib/server-only/rate-limit/compute-quota-flags.ts b/packages/lib/server-only/rate-limit/compute-quota-flags.ts index ea463cf69..556955942 100644 --- a/packages/lib/server-only/rate-limit/compute-quota-flags.ts +++ b/packages/lib/server-only/rate-limit/compute-quota-flags.ts @@ -1,4 +1,4 @@ -import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind'; +import { isQuotaExceeded, isQuotaNearing } from '../../universal/quota-usage'; export type QuotaFlags = { isDocumentQuotaExceeded: boolean; @@ -22,39 +22,6 @@ type ComputeQuotaFlagsOptions = { }; }; -/** - * A quota of `null` means unlimited (never exceeded). A quota of `0` means - * blocked (always exceeded). Otherwise usage `>=` quota is exceeded. - */ -const isQuotaExceeded = (quota: number | null, usage: number): boolean => { - if (quota === null) { - return false; - } - - if (quota === 0) { - return true; - } - - return usage >= quota; -}; - -/** - * A counter is "nearing" its quota once usage reaches the warning threshold - * (80% of the quota, rounded up) but has not yet been exceeded. Nearing and - * exceeded are mutually exclusive per counter. - */ -const isQuotaNearing = (quota: number | null, usage: number): boolean => { - if (quota === null || quota === 0) { - return false; - } - - if (isQuotaExceeded(quota, usage)) { - return false; - } - - return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD); -}; - export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => { return { isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0), diff --git a/packages/lib/server-only/rate-limit/get-quota-alert-kind.ts b/packages/lib/server-only/rate-limit/get-quota-alert-kind.ts index afaaeaa14..3082e864e 100644 --- a/packages/lib/server-only/rate-limit/get-quota-alert-kind.ts +++ b/packages/lib/server-only/rate-limit/get-quota-alert-kind.ts @@ -1,4 +1,4 @@ -export const QUOTA_WARNING_THRESHOLD = 0.8; +import { getQuotaWarningCount } from '../../universal/quota-usage'; export type QuotaAlertKind = 'quota' | 'quotaNearing'; @@ -32,7 +32,7 @@ export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKin // From here newCount < quota, so for tiny quotas (1-4) where the rounded-up // warning threshold equals the quota itself, the warning can never fire — the // exhausting request is handled by the quota branch above. - const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD); + const warningCount = getQuotaWarningCount(quota); const didCrossWarning = newCount >= warningCount && previousCount < warningCount; diff --git a/packages/lib/server-only/recipient/delete-envelope-recipient.ts b/packages/lib/server-only/recipient/delete-envelope-recipient.ts index 06d90ca46..e2e99feed 100644 --- a/packages/lib/server-only/recipient/delete-envelope-recipient.ts +++ b/packages/lib/server-only/recipient/delete-envelope-recipient.ts @@ -1,24 +1,16 @@ -import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { prisma } from '@documenso/prisma'; -import { msg } from '@lingui/core/macro'; import { EnvelopeType, RecipientRole, SendStatus } from '@prisma/client'; -import { createElement } from 'react'; -import { getI18nInstance } from '../../client-only/providers/i18n-server'; -import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { AppError, AppErrorCode } from '../../errors/app-error'; +import { jobs } from '../../jobs/client'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; -import { logger } from '../../utils/logger'; import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients'; -import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; import { buildTeamWhereQuery } from '../../utils/teams'; -import { getEmailContext } from '../email/get-email-context'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; -import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; export interface DeleteEnvelopeRecipientOptions { userId: number; @@ -148,75 +140,16 @@ export const deleteEnvelopeRecipient = async ({ envelope.type === EnvelopeType.DOCUMENT && isRecipientEmailValidForSending(recipientToDelete) ) { - const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - - const template = createElement(RecipientRemovedFromDocumentTemplate, { - documentName: envelope.title, - inviterName: envelope.team?.name || user.name || undefined, - assetBaseUrl, - }); - - const { - branding, - emailLanguage, - senderEmail, - replyToEmail, - organisationId, - claims, - emailsDisabled, - emailTransport, - } = await getEmailContext({ - emailType: 'RECIPIENT', - source: { - type: 'team', - teamId: envelope.teamId, - }, - meta: envelope.documentMeta, - }); - - // Don't send the removal email if the organisation has email sending disabled. - if (emailsDisabled) { - return deletedRecipient; - } - - // Meter the removal email against the organisation email quota/stats. - // Add/remove churn can be used to blast unsolicited removal emails - // outside the email limits. - try { - await assertOrganisationRatesAndLimits({ - organisationId, - organisationClaim: claims, - type: 'email', - count: 1, - }); - } catch (_err) { - logger.warn({ - msg: 'Recipient removed email dropped: org email limit exceeded', - organisationId, - recipientId: recipientToDelete.id, + // Enqueue the "removed from document" email as a background job so a + // transient mail outage doesn't fail the request and the send is retried. + await jobs.triggerJob({ + name: 'send.recipient.removed.email', + payload: { envelopeId: envelope.id, - }); - - return deletedRecipient; - } - - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang: emailLanguage, branding }), - renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), - ]); - - const i18n = await getI18nInstance(emailLanguage); - - await emailTransport.sendMail({ - to: { - address: recipientToDelete.email, - name: recipientToDelete.name, + recipientEmail: recipientToDelete.email, + recipientName: recipientToDelete.name, + inviterName: envelope.team?.name || user.name || undefined, }, - from: senderEmail, - replyTo: replyToEmail, - subject: i18n._(msg`You have been removed from a document`), - html, - text, }); } diff --git a/packages/lib/server-only/recipient/get-recipient-by-id.ts b/packages/lib/server-only/recipient/get-recipient-by-id.ts index 1f0a48855..0a8df960b 100644 --- a/packages/lib/server-only/recipient/get-recipient-by-id.ts +++ b/packages/lib/server-only/recipient/get-recipient-by-id.ts @@ -4,6 +4,7 @@ import { EnvelopeType } from '@prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../../utils/envelope'; import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type GetRecipientByIdOptions = { recipientId: number; @@ -41,6 +42,27 @@ export const getRecipientById = async ({ recipientId, userId, teamId, type }: Ge }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'envelopeId', + id: recipient.envelopeId, + }, + type, + userId, + teamId, + }); + + // Additional validation to check visibility. + const envelope = await prisma.envelope.findUnique({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Recipient not found', + }); + } + const legacyId = { documentId: type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId) : null, templateId: type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId) : null, diff --git a/packages/lib/server-only/recipient/set-document-recipients.ts b/packages/lib/server-only/recipient/set-document-recipients.ts index 2e58936ab..ca2ca666d 100644 --- a/packages/lib/server-only/recipient/set-document-recipients.ts +++ b/packages/lib/server-only/recipient/set-document-recipients.ts @@ -1,4 +1,3 @@ -import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth'; import { type TRecipientActionAuthTypes, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; @@ -7,25 +6,18 @@ import { nanoid } from '@documenso/lib/universal/id'; import { createDocumentAuditLogData, diffRecipientChanges } from '@documenso/lib/utils/document-audit-logs'; import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth'; import { prisma } from '@documenso/prisma'; -import { msg } from '@lingui/core/macro'; import type { Recipient } from '@prisma/client'; import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client'; -import { createElement } from 'react'; import { isDeepEqual } from 'remeda'; -import { getI18nInstance } from '../../client-only/providers/i18n-server'; -import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { AppError, AppErrorCode } from '../../errors/app-error'; +import { jobs } from '../../jobs/client'; import { extractDerivedDocumentEmailSettings } from '../../types/document-email'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; -import { logger } from '../../utils/logger'; import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients'; -import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; -import { getEmailContext } from '../email/get-email-context'; import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role'; -import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; export interface SetDocumentRecipientsOptions { userId: number; @@ -88,16 +80,6 @@ export const setDocumentRecipients = async ({ throw new Error('Document already complete'); } - const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } = - await getEmailContext({ - emailType: 'RECIPIENT', - source: { - type: 'team', - teamId, - }, - meta: envelope.documentMeta, - }); - const recipientsHaveActionAuth = recipients.some( (recipient) => recipient.actionAuth && recipient.actionAuth.length > 0, ); @@ -290,67 +272,29 @@ export const setDocumentRecipients = async ({ const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved; - // Send emails to deleted recipients who have emails. - await Promise.all( - removedRecipients.map(async (recipient) => { - if ( - emailsDisabled || - recipient.sendStatus !== SendStatus.SENT || - recipient.role === RecipientRole.CC || - !isRecipientRemovedEmailEnabled || - !isRecipientEmailValidForSending(recipient) - ) { - return; - } + if (isRecipientRemovedEmailEnabled) { + await Promise.all( + removedRecipients.map(async (recipient) => { + if ( + recipient.sendStatus !== SendStatus.SENT || + recipient.role === RecipientRole.CC || + !isRecipientEmailValidForSending(recipient) + ) { + return; + } - // Meter against the organisation email quota/stats so add/remove churn - // can't be used to send unsolicited "removed" emails outside the limits. - try { - await assertOrganisationRatesAndLimits({ - organisationId, - organisationClaim: claims, - type: 'email', - count: 1, + await jobs.triggerJob({ + name: 'send.recipient.removed.email', + payload: { + envelopeId: envelope.id, + recipientEmail: recipient.email, + recipientName: recipient.name, + inviterName: user.name || undefined, + }, }); - } catch (_err) { - logger.warn({ - msg: 'Recipient removed email dropped: org email limit exceeded', - organisationId, - recipientId: recipient.id, - envelopeId: envelope.id, - }); - - return; - } - - const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000'; - - const template = createElement(RecipientRemovedFromDocumentTemplate, { - documentName: envelope.title, - inviterName: user.name || undefined, - assetBaseUrl, - }); - - const [html, text] = await Promise.all([ - renderEmailWithI18N(template, { lang: emailLanguage, branding }), - renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), - ]); - - const i18n = await getI18nInstance(emailLanguage); - - await emailTransport.sendMail({ - to: { - address: recipient.email, - name: recipient.name, - }, - from: senderEmail, - replyTo: replyToEmail, - subject: i18n._(msg`You have been removed from a document`), - html, - text, - }); - }), - ); + }), + ); + } } // Filter out recipients that have been removed or have been updated. diff --git a/packages/lib/server-only/share/create-or-get-share-link.ts b/packages/lib/server-only/share/create-or-get-share-link.ts index 64842edcb..84917d74b 100644 --- a/packages/lib/server-only/share/create-or-get-share-link.ts +++ b/packages/lib/server-only/share/create-or-get-share-link.ts @@ -5,6 +5,7 @@ import { match, P } from 'ts-pattern'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { alphaid } from '../../universal/id'; import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type CreateSharingIdOptions = | { @@ -27,6 +28,7 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha ), select: { id: true, + teamId: true, }, }); @@ -46,6 +48,31 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha .then((recipient) => recipient?.email); }) .with({ userId: P.number }, async ({ userId }) => { + // Ensure the authenticated user actually has visibility-aware access to the + // envelope before allowing them to create a share link. The share route does + // not carry a teamId, so we derive it from the envelope and reuse the canonical + // visibility check (owner OR team member with sufficient visibility). + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'documentId', + id: documentId, + }, + userId, + teamId: envelope.teamId, + type: EnvelopeType.DOCUMENT, + }); + + const accessibleEnvelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + select: { + id: true, + }, + }); + + if (!accessibleEnvelope) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + return await prisma.user .findFirst({ where: { diff --git a/packages/lib/server-only/team/delete-team.ts b/packages/lib/server-only/team/delete-team.ts index 182a004a0..b9393db84 100644 --- a/packages/lib/server-only/team/delete-team.ts +++ b/packages/lib/server-only/team/delete-team.ts @@ -85,6 +85,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => { // Purge all internal organisation groups that have no teams. await tx.organisationGroup.deleteMany({ where: { + organisationId: team.organisationId, type: OrganisationGroupType.INTERNAL_TEAM, teamGroups: { none: {}, diff --git a/packages/lib/server-only/team/get-team-public-profile.ts b/packages/lib/server-only/team/get-team-public-profile.ts index 1f0b289d0..99e46ced2 100644 --- a/packages/lib/server-only/team/get-team-public-profile.ts +++ b/packages/lib/server-only/team/get-team-public-profile.ts @@ -3,7 +3,6 @@ import type { TeamProfile } from '@prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { buildTeamWhereQuery } from '../../utils/teams'; -import { updateTeamPublicProfile } from './update-team-public-profile'; export type GetTeamPublicProfileOptions = { userId: number; @@ -32,25 +31,24 @@ export const getTeamPublicProfile = async ({ }); } - // Create and return the public profile. + // Lazily initialize a disabled public profile on first access. Membership is + // already verified by the query above, so this system initialization does not + // impose the MANAGE_TEAM gate that updateTeamPublicProfile enforces for writes. if (!team.profile) { - const { url, profile } = await updateTeamPublicProfile({ - userId: userId, - teamId, - data: { + const profile = await prisma.teamProfile.upsert({ + where: { + teamId, + }, + create: { + teamId, enabled: false, }, + update: {}, }); - if (!profile) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'Failed to create public profile', - }); - } - return { profile, - url, + url: team.url, }; } diff --git a/packages/lib/server-only/team/update-team-public-profile.ts b/packages/lib/server-only/team/update-team-public-profile.ts index ca78bd008..c39e31dbc 100644 --- a/packages/lib/server-only/team/update-team-public-profile.ts +++ b/packages/lib/server-only/team/update-team-public-profile.ts @@ -1,3 +1,4 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; import { prisma } from '@documenso/prisma'; import { buildTeamWhereQuery } from '../../utils/teams'; @@ -13,7 +14,11 @@ export type UpdatePublicProfileOptions = { export const updateTeamPublicProfile = async ({ userId, teamId, data }: UpdatePublicProfileOptions) => { return await prisma.team.update({ - where: buildTeamWhereQuery({ teamId, userId }), + where: buildTeamWhereQuery({ + teamId, + userId, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }), data: { profile: { upsert: { diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index f7682d707..e767170b7 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -40,6 +40,7 @@ import { extractDocumentAuthMethods, } from '../../utils/document-auth'; import { mapSecondaryIdToTemplateId } from '../../utils/envelope'; +import { getRecipientsWithMissingFields } from '../../utils/recipients'; import { sendDocument } from '../document/send-document'; import { validateFieldAuth } from '../document/validate-field-auth'; import { incrementDocumentId } from '../envelope/increment-id'; @@ -172,6 +173,17 @@ export const createDocumentFromDirectTemplate = async ({ }); } + const recipientsWithMissingFields = getRecipientsWithMissingFields( + recipients, + recipients.flatMap((recipient) => recipient.fields), + ); + + if (recipientsWithMissingFields.length > 0) { + throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, { + message: 'One or more signers on this direct template are missing a signature field', + }); + } + if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) { throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' }); } diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 114a3c2ef..47158c3f0 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -51,8 +51,8 @@ import { buildTeamWhereQuery } from '../../utils/teams'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { incrementDocumentId } from '../envelope/increment-id'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; -import { resolveSignatureLevel } from '../signature-level/resolve-signature-level'; import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits'; +import { resolveSignatureLevel } from '../signature-level/resolve-signature-level'; import { getTeamSettings } from '../team/get-team-settings'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id'; diff --git a/packages/lib/server-only/user/forgot-password.ts b/packages/lib/server-only/user/forgot-password.ts index 7197f50c7..86d1c1aaa 100644 --- a/packages/lib/server-only/user/forgot-password.ts +++ b/packages/lib/server-only/user/forgot-password.ts @@ -18,31 +18,26 @@ export const forgotPassword = async ({ email }: { email: string }) => { return; } - // Find a token that was created in the last hour and hasn't expired - // const existingToken = await prisma.passwordResetToken.findFirst({ - // where: { - // userId: user.id, - // expiry: { - // gt: new Date(), - // }, - // createdAt: { - // gt: new Date(Date.now() - ONE_HOUR), - // }, - // }, - // }); - - // if (existingToken) { - // return; - // } - const token = crypto.randomBytes(18).toString('hex'); - await prisma.passwordResetToken.create({ - data: { - token, - expiry: new Date(Date.now() + ONE_HOUR), - userId: user.id, - }, + // Invalidate any prior reset tokens for this user before issuing a new one, so + // only a single token is ever live at a time. We still always issue a fresh + // token (and email) so the user can request a new link if a prior email never + // arrived, while bounding the number of usable tokens to one. + await prisma.$transaction(async (tx) => { + await tx.passwordResetToken.deleteMany({ + where: { + userId: user.id, + }, + }); + + await tx.passwordResetToken.create({ + data: { + token, + expiry: new Date(Date.now() + ONE_HOUR), + userId: user.id, + }, + }); }); await sendForgotPassword({ diff --git a/packages/lib/server-only/webhooks/assert-webhook-url.ts b/packages/lib/server-only/webhooks/assert-webhook-url.ts index 9467b5cc3..b89e3d432 100644 --- a/packages/lib/server-only/webhooks/assert-webhook-url.ts +++ b/packages/lib/server-only/webhooks/assert-webhook-url.ts @@ -71,10 +71,17 @@ const isBypassedHost = (url: string): boolean => { }; /** - * Asserts that a webhook URL does not resolve to a private or loopback - * address. Throws an AppError with WEBHOOK_INVALID_REQUEST if it does. + * Assert that a webhook URL does not point at a private/loopback address, + * checking both the literal host and its resolved DNS records. Throws an + * AppError with WEBHOOK_INVALID_REQUEST if it does. Hosts listed in + * NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks. * - * Hosts listed in NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks. + * This is best-effort, non-exhaustive SSRF defence, NOT a complete mitigation. + * It does not cover DNS rebinding (the resolved address can change between this + * check and the actual request), obscure IP encodings, or every IPv6 form, and + * it fails open on lookup errors/timeouts (see the catch below). Network-level + * SSRF protection (firewall/egress rules, blocking internal services and cloud + * metadata endpoints) remains the responsibility of the deployment. */ export const assertNotPrivateUrl = async ( url: string, diff --git a/packages/lib/server-only/webhooks/is-private-url.ts b/packages/lib/server-only/webhooks/is-private-url.ts index 8b345d15a..38f531ee0 100644 --- a/packages/lib/server-only/webhooks/is-private-url.ts +++ b/packages/lib/server-only/webhooks/is-private-url.ts @@ -3,10 +3,11 @@ import { z } from 'zod'; const ZIpSchema = z.string().ip(); /** - * Check whether a URL points to a known private/loopback address. + * Synchronously check whether a URL's host is a known private/loopback address + * (localhost, RFC 1918, link-local, loopback, etc.), regardless of protocol. * - * Performs a synchronous check against known private hostnames and IP ranges. - * Works regardless of the URL protocol. + * Best-effort and non-exhaustive: unrecognised or unparseable hosts return + * `false` (fail open). See `assertNotPrivateUrl` for the full SSRF caveats. */ export const isPrivateUrl = (url: string): boolean => { try { diff --git a/packages/lib/server-only/webhooks/zapier/subscribe.ts b/packages/lib/server-only/webhooks/zapier/subscribe.ts index 370995dd8..c05c99250 100644 --- a/packages/lib/server-only/webhooks/zapier/subscribe.ts +++ b/packages/lib/server-only/webhooks/zapier/subscribe.ts @@ -1,4 +1,6 @@ -import { AppError } from '@documenso/lib/errors/app-error'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { assertNotPrivateUrl } from '../assert-webhook-url'; @@ -9,14 +11,36 @@ export const subscribeHandler = async (req: Request) => { const authorization = req.headers.get('authorization'); if (!authorization) { - return new Response('Unauthorized', { status: 401 }); + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); } const { webhookUrl, eventTrigger } = await req.json(); await assertNotPrivateUrl(webhookUrl); - const result = await validateApiToken({ authorization }); + const result = await validateApiToken({ authorization }).catch(() => { + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); + }); + + const userId = result.userId ?? result.user.id; + const teamId = result.teamId ?? undefined; + + // Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the + // tRPC webhook mutations (create-webhook.ts). Guards against stale-privilege + // use of a token minted while the holder was privileged. + const team = await prisma.team.findFirst({ + where: buildTeamWhereQuery({ + teamId, + userId, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }), + }); + + if (!team) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to manage webhooks for this team', + }); + } const createdWebhook = await prisma.webhook.create({ data: { @@ -24,15 +48,19 @@ export const subscribeHandler = async (req: Request) => { eventTriggers: [eventTrigger], secret: null, enabled: true, - userId: result.userId ?? result.user.id, - teamId: result.teamId ?? undefined, + userId, + teamId, }, }); return Response.json(createdWebhook); } catch (err) { if (err instanceof AppError) { - return Response.json({ message: err.message }, { status: 400 }); + // Map authorization failures to 401, keep other AppErrors as 400 to + // preserve the existing Zapier contract (e.g. invalid webhook URL). + const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400; + + return Response.json({ message: err.message }, { status }); } console.error(err); diff --git a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts index 4772c164b..df21c0b65 100644 --- a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts +++ b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts @@ -1,3 +1,6 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { validateApiToken } from './validateApiToken'; @@ -7,23 +10,42 @@ export const unsubscribeHandler = async (req: Request) => { const authorization = req.headers.get('authorization'); if (!authorization) { - return new Response('Unauthorized', { status: 401 }); + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); } const { webhookId } = await req.json(); - const result = await validateApiToken({ authorization }); + const result = await validateApiToken({ authorization }).catch(() => { + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); + }); + const userId = result.userId ?? result.user.id; + const teamId = result.teamId ?? undefined; + + // Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the + // tRPC delete-webhook-by-id mutation. Guards against stale-privilege use of a + // token minted while the holder was privileged. const deletedWebhook = await prisma.webhook.delete({ where: { id: webhookId, - userId: result.userId ?? result.user.id, - teamId: result.teamId ?? undefined, + team: buildTeamWhereQuery({ + teamId, + userId, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }), }, }); return Response.json(deletedWebhook); } catch (err) { + if (err instanceof AppError) { + // Map authorization failures to 401, keep other AppErrors as 400 to + // preserve the existing Zapier contract. + const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400; + + return Response.json({ message: err.message }, { status }); + } + console.error(err); return Response.json( diff --git a/packages/lib/translations/de/web.po b/packages/lib/translations/de/web.po index f1f748764..311dcf750 100644 --- a/packages/lib/translations/de/web.po +++ b/packages/lib/translations/de/web.po @@ -1416,8 +1416,8 @@ msgid "Add Placeholders" msgstr "Platzhalter hinzufügen" #: apps/remix/app/components/general/rate-limit-array-input.tsx -msgid "Add rate limit" -msgstr "Rate-Limit hinzufügen" +msgid "Add rate limit window" +msgstr "" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Add recipients" @@ -2008,6 +2008,7 @@ msgstr "Jede Quelle" msgid "Any Status" msgstr "Jeder Status" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "API" msgstr "API" @@ -2017,10 +2018,6 @@ msgstr "API" msgid "API key" msgstr "API-Schlüssel" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "API rate limits" -msgstr "API-Rate-Limits" - #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "API requests" msgstr "API-Anfragen" @@ -2441,6 +2438,7 @@ msgstr "Branding-Logo" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding Preferences" msgstr "Markenpräferenzen" @@ -2680,6 +2678,10 @@ msgstr "Unterzeichner kann nicht entfernt werden" msgid "Cannot upload items after the document has been sent" msgstr "Artikel können nicht hochgeladen werden, nachdem das Dokument versendet wurde." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Capabilities enabled for this organisation." +msgstr "" + #: packages/lib/constants/recipient-roles.ts msgctxt "Recipient role name" msgid "Cc" @@ -3572,6 +3574,7 @@ msgid "Currently all organisation members can access this team" msgstr "Derzeit können alle Organisationsmitglieder auf dieses Team zugreifen" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "Zurzeit kann das Branding nur für Teams und darüber konfiguriert werden." @@ -4214,8 +4217,8 @@ msgstr "Dokument storniert" #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts -#: packages/lib/server-only/document/delete-document.ts msgid "Document Cancelled" msgstr "Dokument storniert" @@ -4405,10 +4408,6 @@ msgstr "Dokumenteinstellungen" msgid "Document preferences updated" msgstr "Dokumentpräferenzen aktualisiert" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Document rate limits" -msgstr "Dokumenten-Rate-Limits" - #: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document rejected" @@ -4544,6 +4543,7 @@ msgstr "Dokumentation" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-nav-desktop.tsx #: apps/remix/app/components/general/app-nav-mobile.tsx +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -4993,10 +4993,6 @@ msgstr "E-Mail-Präferenzen" msgid "Email preferences updated" msgstr "E-Mail-Präferenzen aktualisiert" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Email rate limits" -msgstr "E-Mail-Rate-Limits" - #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email recipients when a pending document is deleted" msgstr "Empfänger per E-Mail benachrichtigen, wenn ein ausstehendes Dokument gelöscht wird" @@ -5092,6 +5088,7 @@ msgstr "E-Mail-Verifizierung wurde entfernt" msgid "Email verification has been resent" msgstr "E-Mail-Verifizierung wurde erneut gesendet" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -5105,16 +5102,14 @@ msgstr "E-Mails" msgid "Embedding, 5 members included and more" msgstr "Einbettung, 5 Mitglieder enthalten und mehr" -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Empty = Unlimited, 0 = Blocked" -msgstr "Leer = Unbegrenzt, 0 = Blockiert" - #: packages/ui/primitives/document-flow/add-fields.tsx msgid "Empty field" msgstr "Leeres Feld" +#: apps/remix/app/components/general/claim-limit-fields.tsx +msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." +msgstr "" + #: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx msgid "Enable" msgstr "Aktivieren" @@ -5234,6 +5229,10 @@ msgstr "Stellen Sie sicher, dass Sie das Embedding-Token verwenden und nicht das msgid "Enter" msgstr "Eingeben" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a max request count greater than 0" +msgstr "" + #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." msgstr "Geben Sie einen Namen für Ihren neuen Ordner ein. Ordner helfen Ihnen, Ihre Dateien zu organisieren." @@ -5242,6 +5241,10 @@ msgstr "Geben Sie einen Namen für Ihren neuen Ordner ein. Ordner helfen Ihnen, msgid "Enter a new title" msgstr "Neuen Titel eingeben" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a window, e.g. 5m" +msgstr "" + #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Enter claim name" msgstr "Anspruchsname eingeben" @@ -5500,6 +5503,10 @@ msgstr "Alle haben unterschrieben" msgid "Everyone has signed! You will receive an email copy of the signed document." msgstr "Alle haben unterschrieben! Sie erhalten eine Kopie des unterschriebenen Dokuments per E-Mail." +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Exceeded" +msgstr "" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Exceeded timeout" msgstr "Zeitüberschreitung überschritten" @@ -6763,6 +6770,10 @@ msgstr "Lichtmodus" msgid "Like to have your own public profile with agreements?" msgstr "Möchten Sie Ihr eigenes öffentliches Profil mit Vereinbarungen haben?" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Limit reached" +msgstr "" + #: apps/remix/app/components/general/claim-limit-fields.tsx msgid "Limits" msgstr "Limits" @@ -7068,6 +7079,10 @@ msgstr "MAU (angemeldet)" msgid "Max" msgstr "Max" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Max requests" +msgstr "" + #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." msgstr "Maximale Dateigröße: 4MB. Maximal 100 Zeilen pro Upload. Leere Werte verwenden die Vorlagenstandards." @@ -7114,12 +7129,12 @@ msgstr "Mitglied seit" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -7188,16 +7203,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Monatlich aktive Benutzer: Benutzer, die mindestens eines ihrer Dokumente abgeschlossen haben" #: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly API quota" -msgstr "Monatliches API-Kontingent" +msgid "Monthly quota" +msgstr "" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly document quota" -msgstr "Monatliches Dokumentenkontingent" - -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly email quota" -msgstr "Monatliches E-Mail-Kontingent" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Monthly usage" +msgstr "" #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -7288,7 +7299,6 @@ msgid "Name" msgstr "Name" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Name is required" msgstr "Name ist erforderlich" @@ -7296,6 +7306,10 @@ msgstr "Name ist erforderlich" msgid "Name Settings" msgstr "Einstellungen für Namen" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Near limit" +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Need to sign documents?" msgstr "Müssen Dokumente signieren?" @@ -7435,6 +7449,10 @@ msgstr "Es sind derzeit keine weiteren Maßnahmen Ihrerseits erforderlich." msgid "No groups found" msgstr "Keine Gruppen gefunden" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "No inherited claim" +msgstr "" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No License Configured" msgstr "Keine Lizenz konfiguriert" @@ -7942,6 +7960,11 @@ msgstr "Original" msgid "Otherwise, the document will be created as a draft." msgstr "Andernfalls wird das Dokument als Entwurf erstellt." +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Overlapping fields detected" +msgstr "" + #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Override organisation settings" @@ -8146,6 +8169,10 @@ msgstr "Ausstehende Organisations­einladungen" msgid "Pending since" msgstr "Ausstehend seit" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "People with access to this organisation." +msgstr "" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -8308,10 +8335,6 @@ msgstr "Bitte geben Sie einen aussagekräftigen Namen für Ihr Token ein. Dies w msgid "Please enter a number" msgstr "Bitte gib eine Zahl ein" -#: apps/remix/app/components/general/claim-account.tsx -msgid "Please enter a valid name." -msgstr "Bitte geben Sie einen gültigen Namen ein." - #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Please enter a valid number" msgstr "Bitte geben Sie eine gültige Nummer ein." @@ -8917,6 +8940,11 @@ msgstr "Weiterleitungs-URL" msgid "Redirecting" msgstr "Weiterleitung" +#. placeholder {0}: oidcProviderLabel || 'OIDC' +#: apps/remix/app/routes/_unauthenticated+/signin.tsx +msgid "Redirecting to {0}..." +msgstr "" + #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx msgid "Registration Successful" @@ -9045,6 +9073,10 @@ msgstr "Organisationsmitglied entfernen" msgid "Remove Organisation Member" msgstr "Organisationsmitglied entfernen" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Remove rate limit" +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Remove recipient" msgstr "Empfänger entfernen" @@ -9192,9 +9224,7 @@ msgstr "Umschlag erneut senden" msgid "Resend verification" msgstr "Bestätigung erneut senden" -#: apps/remix/app/components/forms/organisation-update-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx -#: apps/remix/app/components/forms/team-update-form.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Reset" msgstr "Zurücksetzen" @@ -9237,6 +9267,10 @@ msgstr "Zahlung klären" msgid "Resolve payment" msgstr "Zahlung klären" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Resource blocked" +msgstr "" + #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Response" msgstr "Antwort" @@ -9379,6 +9413,7 @@ msgid "Save as Template" msgstr "Als Vorlage speichern" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx msgid "Save changes" msgstr "Änderungen speichern" @@ -9812,6 +9847,10 @@ msgstr "Senden..." msgid "Sent" msgstr "Gesendet" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Sent this period" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" msgstr "Sitzung widerrufen" @@ -10219,6 +10258,11 @@ msgstr "Website Einstellungen" msgid "Skip" msgstr "Überspringen" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." +msgstr "" + #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "Einige Unterzeichner haben noch kein Unterschriftsfeld zugewiesen bekommen. Bitte weisen Sie jedem Unterzeichner mindestens ein Unterschriftsfeld zu, bevor Sie fortfahren." @@ -10789,10 +10833,10 @@ msgid "Team URL" msgstr "Team-URL" #: apps/remix/app/components/general/org-menu-switcher.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx @@ -10803,6 +10847,10 @@ msgstr "Teams" msgid "Teams help you organise your work and collaborate with others. Create your first team to get started." msgstr "Teams helfen Ihnen, Ihre Arbeit zu organisieren und mit anderen zusammenzuarbeiten. Erstellen Sie Ihr erstes Team, um loszulegen." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Teams that belong to this organisation." +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Teams that this organisation group is currently assigned to" msgstr "Teams, denen diese Organisationsgruppe derzeit zugewiesen ist" @@ -12249,6 +12297,7 @@ msgstr "Nicht autorisiert" msgid "Uncompleted" msgstr "Unvollendet" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "Rückgängig" @@ -12280,8 +12329,6 @@ msgstr "Unbekannter Name" #: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Unlimited" msgstr "Unbegrenzt" @@ -12298,6 +12345,10 @@ msgstr "Verknüpfung aufheben" msgid "Unpin" msgstr "Lösen" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "Unsaved changes" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" @@ -12317,9 +12368,6 @@ msgstr "Unbetitelte Gruppe" #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx -#: apps/remix/app/components/forms/branding-preferences-form.tsx -#: apps/remix/app/components/forms/document-preferences-form.tsx -#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -12342,6 +12390,7 @@ msgstr "Banner aktualisieren" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Update Billing" msgstr "Rechnungsdaten aktualisieren" @@ -12369,10 +12418,6 @@ msgstr "E-Mail aktualisieren" msgid "Update Fields" msgstr "Felder aktualisieren" -#: apps/remix/app/components/forms/organisation-update-form.tsx -msgid "Update organisation" -msgstr "Organisation aktualisieren" - #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -12408,10 +12453,6 @@ msgstr "Rolle aktualisieren" msgid "Update Subscription Claim" msgstr "Abonnementsanspruch aktualisieren" -#: apps/remix/app/components/forms/team-update-form.tsx -msgid "Update team" -msgstr "Team aktualisieren" - #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx msgid "Update team email" @@ -12580,15 +12621,18 @@ msgstr "Hochladen" msgid "URL" msgstr "URL" -#. placeholder {0}: selectedStat?.period || 'N/A' -#: apps/remix/app/components/general/organisation-usage-panel.tsx -msgid "Usage for period: {0}" -msgstr "Nutzung für Zeitraum: {0}" - #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx msgid "Use" msgstr "Verwenden" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h" +msgstr "" + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a unique window for each rate limit" +msgstr "" + #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx msgid "Use Authenticator" @@ -12942,7 +12986,7 @@ msgstr "Warten" msgid "Waiting for others" msgstr "Warten auf andere" -#: packages/lib/server-only/document/send-pending-email.ts +#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing." msgstr "Warten auf andere, um die Unterzeichnung abzuschließen." @@ -13490,10 +13534,18 @@ msgstr "Whitelabeling, unbegrenzte Mitglieder und mehr" msgid "Width:" msgstr "Breite:" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Window" +msgstr "" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Withdrawing Consent" msgstr "Zustimmung widerrufen" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Within limit" +msgstr "" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" msgstr "Schreiben Sie eine Beschreibung, die in Ihrem öffentlichen Profil angezeigt wird" @@ -13916,8 +13968,7 @@ msgstr "Du wurdest eingeladen, {0} auf Documenso beizutreten" msgid "You have been invited to join the following organisation" msgstr "Sie wurden eingeladen, der folgenden Organisation beizutreten" -#: packages/lib/server-only/recipient/delete-envelope-recipient.ts -#: packages/lib/server-only/recipient/set-document-recipients.ts +#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts msgid "You have been removed from a document" msgstr "Du wurdest von einem Dokument entfernt" @@ -14037,6 +14088,10 @@ msgstr "Sie haben den Zugriff erfolgreich widerrufen." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "Sie haben das Recht, Ihre Zustimmung zur Verwendung elektronischer Unterschriften jederzeit vor Abschluss des Unterzeichnungsprozesses zu widerrufen. Um Ihre Zustimmung zu widerrufen, kontaktieren Sie bitte den Absender des Dokuments. Sollten Sie den Absender nicht erreichen, können Sie sich für Unterstützung an <0>{SUPPORT_EMAIL} wenden. Seien Sie sich bewusst, dass der Widerruf der Zustimmung den Abschluss der zugehörigen Transaktion oder Dienstleistung verzögern oder stoppen kann." +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "You have unsaved changes" +msgstr "" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You have updated {memberName}." msgstr "Sie haben {memberName} aktualisiert." @@ -14716,4 +14771,3 @@ msgstr "Ihr Verifizierungscode:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/translations/en/web.po b/packages/lib/translations/en/web.po index 454a46e1e..054d2915d 100644 --- a/packages/lib/translations/en/web.po +++ b/packages/lib/translations/en/web.po @@ -1411,8 +1411,8 @@ msgid "Add Placeholders" msgstr "Add Placeholders" #: apps/remix/app/components/general/rate-limit-array-input.tsx -msgid "Add rate limit" -msgstr "Add rate limit" +msgid "Add rate limit window" +msgstr "Add rate limit window" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Add recipients" @@ -2003,6 +2003,7 @@ msgstr "Any Source" msgid "Any Status" msgstr "Any Status" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "API" msgstr "API" @@ -2012,10 +2013,6 @@ msgstr "API" msgid "API key" msgstr "API key" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "API rate limits" -msgstr "API rate limits" - #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "API requests" msgstr "API requests" @@ -2436,6 +2433,7 @@ msgstr "Branding Logo" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding Preferences" msgstr "Branding Preferences" @@ -2675,6 +2673,10 @@ msgstr "Cannot remove signer" msgid "Cannot upload items after the document has been sent" msgstr "Cannot upload items after the document has been sent" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Capabilities enabled for this organisation." +msgstr "Capabilities enabled for this organisation." + #: packages/lib/constants/recipient-roles.ts msgctxt "Recipient role name" msgid "Cc" @@ -3567,6 +3569,7 @@ msgid "Currently all organisation members can access this team" msgstr "Currently all organisation members can access this team" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "Currently branding can only be configured for Teams and above plans." @@ -4209,8 +4212,8 @@ msgstr "Document cancelled" #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts -#: packages/lib/server-only/document/delete-document.ts msgid "Document Cancelled" msgstr "Document Cancelled" @@ -4400,10 +4403,6 @@ msgstr "Document Preferences" msgid "Document preferences updated" msgstr "Document preferences updated" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Document rate limits" -msgstr "Document rate limits" - #: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document rejected" @@ -4539,6 +4538,7 @@ msgstr "Documentation" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-nav-desktop.tsx #: apps/remix/app/components/general/app-nav-mobile.tsx +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -4988,10 +4988,6 @@ msgstr "Email Preferences" msgid "Email preferences updated" msgstr "Email preferences updated" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Email rate limits" -msgstr "Email rate limits" - #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email recipients when a pending document is deleted" msgstr "Email recipients when a pending document is deleted" @@ -5087,6 +5083,7 @@ msgstr "Email verification has been removed" msgid "Email verification has been resent" msgstr "Email verification has been resent" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -5100,16 +5097,14 @@ msgstr "Emails" msgid "Embedding, 5 members included and more" msgstr "Embedding, 5 members included and more" -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Empty = Unlimited, 0 = Blocked" -msgstr "Empty = Unlimited, 0 = Blocked" - #: packages/ui/primitives/document-flow/add-fields.tsx msgid "Empty field" msgstr "Empty field" +#: apps/remix/app/components/general/claim-limit-fields.tsx +msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." +msgstr "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." + #: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx msgid "Enable" msgstr "Enable" @@ -5229,6 +5224,10 @@ msgstr "Ensure that you are using the embedding token, not the API token" msgid "Enter" msgstr "Enter" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a max request count greater than 0" +msgstr "Enter a max request count greater than 0" + #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." msgstr "Enter a name for your new folder. Folders help you organise your items." @@ -5237,6 +5236,10 @@ msgstr "Enter a name for your new folder. Folders help you organise your items." msgid "Enter a new title" msgstr "Enter a new title" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a window, e.g. 5m" +msgstr "Enter a window, e.g. 5m" + #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Enter claim name" msgstr "Enter claim name" @@ -5495,6 +5498,10 @@ msgstr "Everyone has signed" msgid "Everyone has signed! You will receive an email copy of the signed document." msgstr "Everyone has signed! You will receive an email copy of the signed document." +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Exceeded" +msgstr "Exceeded" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Exceeded timeout" msgstr "Exceeded timeout" @@ -6758,6 +6765,10 @@ msgstr "Light Mode" msgid "Like to have your own public profile with agreements?" msgstr "Like to have your own public profile with agreements?" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Limit reached" +msgstr "Limit reached" + #: apps/remix/app/components/general/claim-limit-fields.tsx msgid "Limits" msgstr "Limits" @@ -7063,6 +7074,10 @@ msgstr "MAU (signed in)" msgid "Max" msgstr "Max" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Max requests" +msgstr "Max requests" + #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." msgstr "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." @@ -7109,12 +7124,12 @@ msgstr "Member Since" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -7183,16 +7198,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Monthly Active Users: Users that had at least one of their documents completed" #: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly API quota" -msgstr "Monthly API quota" +msgid "Monthly quota" +msgstr "Monthly quota" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly document quota" -msgstr "Monthly document quota" - -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly email quota" -msgstr "Monthly email quota" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Monthly usage" +msgstr "Monthly usage" #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -7283,7 +7294,6 @@ msgid "Name" msgstr "Name" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Name is required" msgstr "Name is required" @@ -7291,6 +7301,10 @@ msgstr "Name is required" msgid "Name Settings" msgstr "Name Settings" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Near limit" +msgstr "Near limit" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Need to sign documents?" msgstr "Need to sign documents?" @@ -7430,6 +7444,10 @@ msgstr "No further action is required from you at this time." msgid "No groups found" msgstr "No groups found" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "No inherited claim" +msgstr "No inherited claim" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No License Configured" msgstr "No License Configured" @@ -7937,6 +7955,11 @@ msgstr "Original" msgid "Otherwise, the document will be created as a draft." msgstr "Otherwise, the document will be created as a draft." +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Overlapping fields detected" +msgstr "Overlapping fields detected" + #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Override organisation settings" @@ -8141,6 +8164,10 @@ msgstr "Pending Organisation Invites" msgid "Pending since" msgstr "Pending since" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "People with access to this organisation." +msgstr "People with access to this organisation." + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -8303,10 +8330,6 @@ msgstr "Please enter a meaningful name for your token. This will help you identi msgid "Please enter a number" msgstr "Please enter a number" -#: apps/remix/app/components/general/claim-account.tsx -msgid "Please enter a valid name." -msgstr "Please enter a valid name." - #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Please enter a valid number" msgstr "Please enter a valid number" @@ -8772,6 +8795,10 @@ msgstr "Recipient ID:" msgid "Recipient rejected the document" msgstr "Recipient rejected the document" +#: packages/lib/utils/document-audit-logs.ts +msgid "Recipient rejected the document externally" +msgstr "Recipient rejected the document externally" + #: apps/remix/app/components/general/admin-global-settings-section.tsx msgid "Recipient removed" msgstr "Recipient removed" @@ -8908,6 +8935,11 @@ msgstr "Redirect URL" msgid "Redirecting" msgstr "Redirecting" +#. placeholder {0}: oidcProviderLabel || 'OIDC' +#: apps/remix/app/routes/_unauthenticated+/signin.tsx +msgid "Redirecting to {0}..." +msgstr "Redirecting to {0}..." + #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx msgid "Registration Successful" @@ -9036,6 +9068,10 @@ msgstr "Remove organisation member" msgid "Remove Organisation Member" msgstr "Remove Organisation Member" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Remove rate limit" +msgstr "Remove rate limit" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Remove recipient" msgstr "Remove recipient" @@ -9183,9 +9219,7 @@ msgstr "Resend Envelope" msgid "Resend verification" msgstr "Resend verification" -#: apps/remix/app/components/forms/organisation-update-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx -#: apps/remix/app/components/forms/team-update-form.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Reset" msgstr "Reset" @@ -9228,6 +9262,10 @@ msgstr "Resolve" msgid "Resolve payment" msgstr "Resolve payment" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Resource blocked" +msgstr "Resource blocked" + #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Response" msgstr "Response" @@ -9370,6 +9408,7 @@ msgid "Save as Template" msgstr "Save as Template" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx msgid "Save changes" msgstr "Save changes" @@ -9803,6 +9842,10 @@ msgstr "Sending..." msgid "Sent" msgstr "Sent" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Sent this period" +msgstr "Sent this period" + #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" msgstr "Session revoked" @@ -10210,6 +10253,11 @@ msgstr "Site Settings" msgid "Skip" msgstr "Skip" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." +msgstr "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." + #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." @@ -10780,10 +10828,10 @@ msgid "Team URL" msgstr "Team URL" #: apps/remix/app/components/general/org-menu-switcher.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx @@ -10794,6 +10842,10 @@ msgstr "Teams" msgid "Teams help you organise your work and collaborate with others. Create your first team to get started." msgstr "Teams help you organise your work and collaborate with others. Create your first team to get started." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Teams that belong to this organisation." +msgstr "Teams that belong to this organisation." + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Teams that this organisation group is currently assigned to" msgstr "Teams that this organisation group is currently assigned to" @@ -11088,6 +11140,23 @@ msgstr "The document signing process will be stopped" msgid "The document was created but could not be sent to recipients." msgstr "The document was created but could not be sent to recipients." +#: packages/lib/utils/document-audit-logs.ts +msgid "The document was rejected externally by {onBehalfOf} on behalf of {user}" +msgstr "The document was rejected externally by {onBehalfOf} on behalf of {user}" + +#: packages/lib/utils/document-audit-logs.ts +#: packages/lib/utils/document-audit-logs.ts +msgid "The document was rejected externally by {onBehalfOf} on behalf of the recipient" +msgstr "The document was rejected externally by {onBehalfOf} on behalf of the recipient" + +#: packages/lib/utils/document-audit-logs.ts +msgid "The document was rejected externally on behalf of {user}" +msgstr "The document was rejected externally on behalf of {user}" + +#: packages/lib/utils/document-audit-logs.ts +msgid "The document was rejected externally on behalf of the recipient" +msgstr "The document was rejected externally on behalf of the recipient" + #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx msgid "The document will be hidden from your account" msgstr "The document will be hidden from your account" @@ -12223,6 +12292,7 @@ msgstr "Unauthorized" msgid "Uncompleted" msgstr "Uncompleted" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "Undo" @@ -12254,8 +12324,6 @@ msgstr "Unknown name" #: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Unlimited" msgstr "Unlimited" @@ -12272,6 +12340,10 @@ msgstr "Unlink" msgid "Unpin" msgstr "Unpin" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "Unsaved changes" +msgstr "Unsaved changes" + #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" @@ -12291,9 +12363,6 @@ msgstr "Untitled Group" #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx -#: apps/remix/app/components/forms/branding-preferences-form.tsx -#: apps/remix/app/components/forms/document-preferences-form.tsx -#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -12316,6 +12385,7 @@ msgstr "Update Banner" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Update Billing" msgstr "Update Billing" @@ -12343,10 +12413,6 @@ msgstr "Update email" msgid "Update Fields" msgstr "Update Fields" -#: apps/remix/app/components/forms/organisation-update-form.tsx -msgid "Update organisation" -msgstr "Update organisation" - #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -12382,10 +12448,6 @@ msgstr "Update role" msgid "Update Subscription Claim" msgstr "Update Subscription Claim" -#: apps/remix/app/components/forms/team-update-form.tsx -msgid "Update team" -msgstr "Update team" - #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx msgid "Update team email" @@ -12554,15 +12616,18 @@ msgstr "Uploading" msgid "URL" msgstr "URL" -#. placeholder {0}: selectedStat?.period || 'N/A' -#: apps/remix/app/components/general/organisation-usage-panel.tsx -msgid "Usage for period: {0}" -msgstr "Usage for period: {0}" - #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx msgid "Use" msgstr "Use" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h" +msgstr "Use a duration with a unit, e.g. 5m, 1h, or 24h" + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a unique window for each rate limit" +msgstr "Use a unique window for each rate limit" + #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx msgid "Use Authenticator" @@ -12916,7 +12981,7 @@ msgstr "Waiting" msgid "Waiting for others" msgstr "Waiting for others" -#: packages/lib/server-only/document/send-pending-email.ts +#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing." msgstr "Waiting for others to complete signing." @@ -13464,10 +13529,18 @@ msgstr "Whitelabeling, unlimited members and more" msgid "Width:" msgstr "Width:" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Window" +msgstr "Window" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Withdrawing Consent" msgstr "Withdrawing Consent" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Within limit" +msgstr "Within limit" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" msgstr "Write a description to display on your public profile" @@ -13890,8 +13963,7 @@ msgstr "You have been invited to join {0} on Documenso" msgid "You have been invited to join the following organisation" msgstr "You have been invited to join the following organisation" -#: packages/lib/server-only/recipient/delete-envelope-recipient.ts -#: packages/lib/server-only/recipient/set-document-recipients.ts +#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts msgid "You have been removed from a document" msgstr "You have been removed from a document" @@ -14011,6 +14083,10 @@ msgstr "You have successfully revoked access." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "You have unsaved changes" +msgstr "You have unsaved changes" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You have updated {memberName}." msgstr "You have updated {memberName}." diff --git a/packages/lib/translations/es/web.po b/packages/lib/translations/es/web.po index 4eef75f10..b3bedc425 100644 --- a/packages/lib/translations/es/web.po +++ b/packages/lib/translations/es/web.po @@ -1416,8 +1416,8 @@ msgid "Add Placeholders" msgstr "Agregar Marcadores de posición" #: apps/remix/app/components/general/rate-limit-array-input.tsx -msgid "Add rate limit" -msgstr "Agregar límite de velocidad" +msgid "Add rate limit window" +msgstr "" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Add recipients" @@ -2008,6 +2008,7 @@ msgstr "Cualquier fuente" msgid "Any Status" msgstr "Cualquier estado" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "API" msgstr "API" @@ -2017,10 +2018,6 @@ msgstr "API" msgid "API key" msgstr "Clave API" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "API rate limits" -msgstr "Límites de velocidad de la API" - #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "API requests" msgstr "Solicitudes de API" @@ -2441,6 +2438,7 @@ msgstr "Logotipo de Marca" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding Preferences" msgstr "Preferencias de marca" @@ -2680,6 +2678,10 @@ msgstr "No se puede eliminar el firmante" msgid "Cannot upload items after the document has been sent" msgstr "No se pueden cargar elementos después de que el documento ha sido enviado" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Capabilities enabled for this organisation." +msgstr "" + #: packages/lib/constants/recipient-roles.ts msgctxt "Recipient role name" msgid "Cc" @@ -3572,6 +3574,7 @@ msgid "Currently all organisation members can access this team" msgstr "Actualmente, todos los miembros de la organización pueden acceder a este equipo" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "Actualmente la marca solo se puede configurar para Equipos y planes superiores." @@ -4214,8 +4217,8 @@ msgstr "Documento cancelado" #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts -#: packages/lib/server-only/document/delete-document.ts msgid "Document Cancelled" msgstr "Documento cancelado" @@ -4405,10 +4408,6 @@ msgstr "Preferencias del documento" msgid "Document preferences updated" msgstr "Preferencias del documento actualizadas" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Document rate limits" -msgstr "Límites de velocidad de documentos" - #: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document rejected" @@ -4544,6 +4543,7 @@ msgstr "Documentación" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-nav-desktop.tsx #: apps/remix/app/components/general/app-nav-mobile.tsx +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -4993,10 +4993,6 @@ msgstr "Preferencias de correo electrónico" msgid "Email preferences updated" msgstr "Preferencias de correo electrónico actualizadas" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Email rate limits" -msgstr "Límites de velocidad de correo electrónico" - #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email recipients when a pending document is deleted" msgstr "Enviar un correo electrónico a los destinatarios cuando se elimine un documento pendiente" @@ -5092,6 +5088,7 @@ msgstr "La verificación de correo electrónico ha sido eliminada" msgid "Email verification has been resent" msgstr "La verificación de correo electrónico ha sido reenviada" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -5105,16 +5102,14 @@ msgstr "Correos electrónicos" msgid "Embedding, 5 members included and more" msgstr "Incrustación, 5 miembros incluidos y más" -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Empty = Unlimited, 0 = Blocked" -msgstr "Vacío = Ilimitado, 0 = Bloqueado" - #: packages/ui/primitives/document-flow/add-fields.tsx msgid "Empty field" msgstr "Campo vacío" +#: apps/remix/app/components/general/claim-limit-fields.tsx +msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." +msgstr "" + #: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx msgid "Enable" msgstr "Habilitar" @@ -5234,6 +5229,10 @@ msgstr "Asegúrate de que estás utilizando el token de incrustación, no el tok msgid "Enter" msgstr "Ingresar" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a max request count greater than 0" +msgstr "" + #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." msgstr "Ingrese un nombre para su nueva carpeta. Las carpetas le ayudan a organizar sus elementos." @@ -5242,6 +5241,10 @@ msgstr "Ingrese un nombre para su nueva carpeta. Las carpetas le ayudan a organi msgid "Enter a new title" msgstr "Introduce un nuevo título" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a window, e.g. 5m" +msgstr "" + #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Enter claim name" msgstr "Ingresar nombre de la reclamación" @@ -5500,6 +5503,10 @@ msgstr "Todos han firmado" msgid "Everyone has signed! You will receive an email copy of the signed document." msgstr "¡Todos han firmado! Recibirás una copia del documento firmado por correo electrónico." +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Exceeded" +msgstr "" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Exceeded timeout" msgstr "Tiempo de espera excedido" @@ -6763,6 +6770,10 @@ msgstr "Modo claro" msgid "Like to have your own public profile with agreements?" msgstr "¿Te gustaría tener tu propio perfil público con acuerdos?" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Limit reached" +msgstr "" + #: apps/remix/app/components/general/claim-limit-fields.tsx msgid "Limits" msgstr "Límites" @@ -7068,6 +7079,10 @@ msgstr "MAU (con sesión iniciada)" msgid "Max" msgstr "Máx" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Max requests" +msgstr "" + #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." msgstr "Tamaño máximo de archivo: 4MB. Máximo 100 filas por carga. Los valores en blanco usarán los valores predeterminados de la plantilla." @@ -7114,12 +7129,12 @@ msgstr "Miembro desde" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -7188,16 +7203,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Usuarios activos mensuales: Usuarios que completaron al menos uno de sus documentos" #: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly API quota" -msgstr "Cuota mensual de API" +msgid "Monthly quota" +msgstr "" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly document quota" -msgstr "Cuota mensual de documentos" - -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly email quota" -msgstr "Cuota mensual de correos electrónicos" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Monthly usage" +msgstr "" #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -7288,7 +7299,6 @@ msgid "Name" msgstr "Nombre" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Name is required" msgstr "Se requiere el nombre" @@ -7296,6 +7306,10 @@ msgstr "Se requiere el nombre" msgid "Name Settings" msgstr "Configuración de Nombre" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Near limit" +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Need to sign documents?" msgstr "¿Necesitas firmar documentos?" @@ -7435,6 +7449,10 @@ msgstr "No further action is required from you at this time." msgid "No groups found" msgstr "No se encontraron grupos" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "No inherited claim" +msgstr "" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No License Configured" msgstr "Licencia no configurada" @@ -7942,6 +7960,11 @@ msgstr "Original" msgid "Otherwise, the document will be created as a draft." msgstr "De lo contrario, el documento se creará como un borrador." +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Overlapping fields detected" +msgstr "" + #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Override organisation settings" @@ -8146,6 +8169,10 @@ msgstr "Invitaciones pendientes de la organización" msgid "Pending since" msgstr "Pendiente desde" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "People with access to this organisation." +msgstr "" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -8308,10 +8335,6 @@ msgstr "Por favor, ingresa un nombre significativo para tu token. Esto te ayudar msgid "Please enter a number" msgstr "Por favor ingresa un número" -#: apps/remix/app/components/general/claim-account.tsx -msgid "Please enter a valid name." -msgstr "Por favor, introduce un nombre válido." - #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Please enter a valid number" msgstr "Por favor, ingresa un número válido" @@ -8917,6 +8940,11 @@ msgstr "URL de redirección" msgid "Redirecting" msgstr "Redireccionando" +#. placeholder {0}: oidcProviderLabel || 'OIDC' +#: apps/remix/app/routes/_unauthenticated+/signin.tsx +msgid "Redirecting to {0}..." +msgstr "" + #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx msgid "Registration Successful" @@ -9045,6 +9073,10 @@ msgstr "Eliminar miembro de la organización" msgid "Remove Organisation Member" msgstr "Eliminar miembro de la organización" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Remove rate limit" +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Remove recipient" msgstr "Eliminar destinatario" @@ -9192,9 +9224,7 @@ msgstr "Reenviar sobre (envelope)" msgid "Resend verification" msgstr "Reenviar verificación" -#: apps/remix/app/components/forms/organisation-update-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx -#: apps/remix/app/components/forms/team-update-form.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Reset" msgstr "Restablecer" @@ -9237,6 +9267,10 @@ msgstr "Resolver" msgid "Resolve payment" msgstr "Resolver pago" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Resource blocked" +msgstr "" + #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Response" msgstr "Respuesta" @@ -9379,6 +9413,7 @@ msgid "Save as Template" msgstr "Guardar como plantilla" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx msgid "Save changes" msgstr "Guardar cambios" @@ -9812,6 +9847,10 @@ msgstr "Enviando..." msgid "Sent" msgstr "Enviado" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Sent this period" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" msgstr "Sesión revocada" @@ -10219,6 +10258,11 @@ msgstr "Configuraciones del sitio" msgid "Skip" msgstr "Omitir" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." +msgstr "" + #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "Algunos firmantes no han sido asignados a un campo de firma. Asigne al menos 1 campo de firma a cada firmante antes de continuar." @@ -10789,10 +10833,10 @@ msgid "Team URL" msgstr "URL del equipo" #: apps/remix/app/components/general/org-menu-switcher.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx @@ -10803,6 +10847,10 @@ msgstr "Equipos" msgid "Teams help you organise your work and collaborate with others. Create your first team to get started." msgstr "Los equipos te ayudan a organizar tu trabajo y colaborar con otros. Crea tu primer equipo para comenzar." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Teams that belong to this organisation." +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Teams that this organisation group is currently assigned to" msgstr "Equipos a los que actualmente está asignado este grupo de organización" @@ -12249,6 +12297,7 @@ msgstr "No autorizado" msgid "Uncompleted" msgstr "Incompleto" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "Deshacer" @@ -12280,8 +12329,6 @@ msgstr "Nombre desconocido" #: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Unlimited" msgstr "Ilimitado" @@ -12298,6 +12345,10 @@ msgstr "Desvincular" msgid "Unpin" msgstr "Desanclar" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "Unsaved changes" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" @@ -12317,9 +12368,6 @@ msgstr "Grupo sin título" #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx -#: apps/remix/app/components/forms/branding-preferences-form.tsx -#: apps/remix/app/components/forms/document-preferences-form.tsx -#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -12342,6 +12390,7 @@ msgstr "Actualizar banner" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Update Billing" msgstr "Actualizar facturación" @@ -12369,10 +12418,6 @@ msgstr "Actualizar correo electrónico" msgid "Update Fields" msgstr "Actualizar Campos" -#: apps/remix/app/components/forms/organisation-update-form.tsx -msgid "Update organisation" -msgstr "Actualizar organización" - #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -12408,10 +12453,6 @@ msgstr "Actualizar rol" msgid "Update Subscription Claim" msgstr "Actualizar reclamo de suscripción" -#: apps/remix/app/components/forms/team-update-form.tsx -msgid "Update team" -msgstr "Actualizar equipo" - #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx msgid "Update team email" @@ -12580,15 +12621,18 @@ msgstr "Subiendo" msgid "URL" msgstr "URL" -#. placeholder {0}: selectedStat?.period || 'N/A' -#: apps/remix/app/components/general/organisation-usage-panel.tsx -msgid "Usage for period: {0}" -msgstr "Uso para el período: {0}" - #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx msgid "Use" msgstr "Usar" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h" +msgstr "" + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a unique window for each rate limit" +msgstr "" + #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx msgid "Use Authenticator" @@ -12942,7 +12986,7 @@ msgstr "Esperando" msgid "Waiting for others" msgstr "Esperando a otros" -#: packages/lib/server-only/document/send-pending-email.ts +#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing." msgstr "Esperando a que otros completen la firma." @@ -13490,10 +13534,18 @@ msgstr "Etiqueta blanca, miembros ilimitados y más" msgid "Width:" msgstr "Ancho:" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Window" +msgstr "" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Withdrawing Consent" msgstr "Retirar Consentimiento" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Within limit" +msgstr "" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" msgstr "Escribe una descripción para mostrar en tu perfil público" @@ -13916,8 +13968,7 @@ msgstr "Te han invitado a unirte a {0} en Documenso" msgid "You have been invited to join the following organisation" msgstr "Has sido invitado a unirte a la siguiente organización" -#: packages/lib/server-only/recipient/delete-envelope-recipient.ts -#: packages/lib/server-only/recipient/set-document-recipients.ts +#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts msgid "You have been removed from a document" msgstr "Te han eliminado de un documento" @@ -14037,6 +14088,10 @@ msgstr "Has revocado el acceso con éxito." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "Usted tiene el derecho de retirar su consentimiento para usar firmas electrónicas en cualquier momento antes de completar el proceso de firma. Para retirar su consentimiento, comuníquese con el remitente del documento. Si no se comunica con el remitente, puede comunicarse con <0>{SUPPORT_EMAIL} para obtener asistencia. Tenga en cuenta que retirar el consentimiento puede retrasar o detener la finalización de la transacción o servicio relacionado." +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "You have unsaved changes" +msgstr "" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You have updated {memberName}." msgstr "Has actualizado a {memberName}." @@ -14716,4 +14771,3 @@ msgstr "Su código de verificación:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "su-dominio.com otro-dominio.com" - diff --git a/packages/lib/translations/fr/web.po b/packages/lib/translations/fr/web.po index 1305373bc..db950a4b2 100644 --- a/packages/lib/translations/fr/web.po +++ b/packages/lib/translations/fr/web.po @@ -1416,8 +1416,8 @@ msgid "Add Placeholders" msgstr "Ajouter des espaces réservés" #: apps/remix/app/components/general/rate-limit-array-input.tsx -msgid "Add rate limit" -msgstr "Ajouter une limite de fréquence" +msgid "Add rate limit window" +msgstr "" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Add recipients" @@ -2008,6 +2008,7 @@ msgstr "Toute source" msgid "Any Status" msgstr "Tout statut" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "API" msgstr "API" @@ -2017,10 +2018,6 @@ msgstr "API" msgid "API key" msgstr "Clé d’API" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "API rate limits" -msgstr "Limites de fréquence de l’API" - #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "API requests" msgstr "Requêtes API" @@ -2441,6 +2438,7 @@ msgstr "Logo de la marque" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding Preferences" msgstr "Préférences de branding" @@ -2680,6 +2678,10 @@ msgstr "Impossible de supprimer le signataire" msgid "Cannot upload items after the document has been sent" msgstr "Impossible de télécharger des éléments après l'envoi du document" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Capabilities enabled for this organisation." +msgstr "" + #: packages/lib/constants/recipient-roles.ts msgctxt "Recipient role name" msgid "Cc" @@ -3572,6 +3574,7 @@ msgid "Currently all organisation members can access this team" msgstr "Actuellement, tous les membres de l'organisation peuvent accéder à cette équipe" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "Actuellement, la personnalisation de la marque ne peut être configurée que pour les plans Équipe et plus." @@ -4214,8 +4217,8 @@ msgstr "Document annulé" #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts -#: packages/lib/server-only/document/delete-document.ts msgid "Document Cancelled" msgstr "Document Annulé" @@ -4405,10 +4408,6 @@ msgstr "Préférences de document" msgid "Document preferences updated" msgstr "Préférences de document mises à jour" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Document rate limits" -msgstr "Limites de fréquence des documents" - #: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document rejected" @@ -4544,6 +4543,7 @@ msgstr "Documentation" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-nav-desktop.tsx #: apps/remix/app/components/general/app-nav-mobile.tsx +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -4993,10 +4993,6 @@ msgstr "Préférences de messagerie" msgid "Email preferences updated" msgstr "Préférences de messagerie mises à jour" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Email rate limits" -msgstr "Limites de fréquence des e-mails" - #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email recipients when a pending document is deleted" msgstr "Envoyer un e-mail aux destinataires lorsqu’un document en attente est supprimé" @@ -5092,6 +5088,7 @@ msgstr "La vérification par email a été supprimée" msgid "Email verification has been resent" msgstr "La vérification par email a été renvoyée" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -5105,16 +5102,14 @@ msgstr "E-mails" msgid "Embedding, 5 members included and more" msgstr "Intégration, 5 membres inclus et plus" -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Empty = Unlimited, 0 = Blocked" -msgstr "Vide = Illimité, 0 = Bloqué" - #: packages/ui/primitives/document-flow/add-fields.tsx msgid "Empty field" msgstr "Champ vide" +#: apps/remix/app/components/general/claim-limit-fields.tsx +msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." +msgstr "" + #: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx msgid "Enable" msgstr "Activer" @@ -5234,6 +5229,10 @@ msgstr "Assurez-vous d’utiliser le jeton d’intégration, et non le jeton d msgid "Enter" msgstr "Entrer" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a max request count greater than 0" +msgstr "" + #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." msgstr "Entrez un nom pour votre nouveau dossier. Les dossiers vous aident à organiser vos éléments." @@ -5242,6 +5241,10 @@ msgstr "Entrez un nom pour votre nouveau dossier. Les dossiers vous aident à or msgid "Enter a new title" msgstr "Saisissez un nouveau titre" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a window, e.g. 5m" +msgstr "" + #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Enter claim name" msgstr "Entrez le nom de la réclamation" @@ -5500,6 +5503,10 @@ msgstr "Tout le monde a signé" msgid "Everyone has signed! You will receive an email copy of the signed document." msgstr "Tout le monde a signé ! Vous recevrez une copie du document signé par e-mail." +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Exceeded" +msgstr "" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Exceeded timeout" msgstr "Délai dépassé" @@ -6763,6 +6770,10 @@ msgstr "Mode clair" msgid "Like to have your own public profile with agreements?" msgstr "Vous voulez avoir votre propre profil public avec des accords ?" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Limit reached" +msgstr "" + #: apps/remix/app/components/general/claim-limit-fields.tsx msgid "Limits" msgstr "Limites" @@ -7068,6 +7079,10 @@ msgstr "MAU (connecté)" msgid "Max" msgstr "Maximum" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Max requests" +msgstr "" + #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." msgstr "Taille maximale du fichier : 4 Mo. Maximum de 100 lignes par importation. Les valeurs vides utiliseront les valeurs par défaut du modèle." @@ -7114,12 +7129,12 @@ msgstr "Membre depuis" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -7188,16 +7203,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Utilisateurs actifs mensuels : utilisateurs ayant terminé au moins un de leurs documents" #: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly API quota" -msgstr "Quota d’API mensuel" +msgid "Monthly quota" +msgstr "" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly document quota" -msgstr "Quota de documents mensuel" - -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly email quota" -msgstr "Quota d’e-mails mensuel" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Monthly usage" +msgstr "" #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -7288,7 +7299,6 @@ msgid "Name" msgstr "Nom" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Name is required" msgstr "Le nom est requis" @@ -7296,6 +7306,10 @@ msgstr "Le nom est requis" msgid "Name Settings" msgstr "Paramètres du nom" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Near limit" +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Need to sign documents?" msgstr "Besoin de signer des documents ?" @@ -7435,6 +7449,10 @@ msgstr "Aucune autre action n'est requise de votre part pour le moment." msgid "No groups found" msgstr "Aucun groupe trouvé" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "No inherited claim" +msgstr "" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No License Configured" msgstr "Aucune licence configurée" @@ -7942,6 +7960,11 @@ msgstr "Original" msgid "Otherwise, the document will be created as a draft." msgstr "Sinon, le document sera créé sous forme de brouillon." +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Overlapping fields detected" +msgstr "" + #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Override organisation settings" @@ -8146,6 +8169,10 @@ msgstr "Invitations à l’organisation en attente" msgid "Pending since" msgstr "En attente depuis" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "People with access to this organisation." +msgstr "" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -8308,10 +8335,6 @@ msgstr "Veuillez entrer un nom significatif pour votre token. Cela vous aidera msgid "Please enter a number" msgstr "Veuillez entrer un nombre" -#: apps/remix/app/components/general/claim-account.tsx -msgid "Please enter a valid name." -msgstr "Veuiillez entrer un nom valide." - #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Please enter a valid number" msgstr "Veuillez entrer un numéro valide" @@ -8424,7 +8447,7 @@ msgstr "Veuillez réessayer ou contacter notre support." #. placeholder {0}: `'${t(deleteMessage)}'` #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx msgid "Please type {0} to confirm" -msgstr "Veuiillez taper {0} pour confirmer" +msgstr "Veuillez taper {0} pour confirmer" #. placeholder {0}: user.email #: apps/remix/app/components/dialogs/account-delete-dialog.tsx @@ -8917,6 +8940,11 @@ msgstr "URL de redirection" msgid "Redirecting" msgstr "Redirection" +#. placeholder {0}: oidcProviderLabel || 'OIDC' +#: apps/remix/app/routes/_unauthenticated+/signin.tsx +msgid "Redirecting to {0}..." +msgstr "" + #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx msgid "Registration Successful" @@ -9045,6 +9073,10 @@ msgstr "Supprimer le membre de l'organisation" msgid "Remove Organisation Member" msgstr "Supprimer un membre de l’organisation" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Remove rate limit" +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Remove recipient" msgstr "Supprimer le destinataire" @@ -9192,9 +9224,7 @@ msgstr "Renvoyer l’enveloppe" msgid "Resend verification" msgstr "Renvoyer la vérification" -#: apps/remix/app/components/forms/organisation-update-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx -#: apps/remix/app/components/forms/team-update-form.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Reset" msgstr "Réinitialiser" @@ -9237,6 +9267,10 @@ msgstr "Résoudre" msgid "Resolve payment" msgstr "Résoudre le paiement" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Resource blocked" +msgstr "" + #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Response" msgstr "Réponse" @@ -9379,6 +9413,7 @@ msgid "Save as Template" msgstr "Enregistrer comme modèle" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx msgid "Save changes" msgstr "Enregistrer les modifications" @@ -9812,6 +9847,10 @@ msgstr "Envoi..." msgid "Sent" msgstr "Envoyé" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Sent this period" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" msgstr "Session révoquée" @@ -10219,6 +10258,11 @@ msgstr "Paramètres du site" msgid "Skip" msgstr "Ignorer" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." +msgstr "" + #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "Certains signataires n'ont pas été assignés à un champ de signature. Veuillez assigner au moins 1 champ de signature à chaque signataire avant de continuer." @@ -10789,10 +10833,10 @@ msgid "Team URL" msgstr "URL de l'équipe" #: apps/remix/app/components/general/org-menu-switcher.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx @@ -10803,6 +10847,10 @@ msgstr "Équipes" msgid "Teams help you organise your work and collaborate with others. Create your first team to get started." msgstr "Les équipes vous aident à organiser votre travail et à collaborer avec d'autres. Créez votre première équipe pour commencer." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Teams that belong to this organisation." +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Teams that this organisation group is currently assigned to" msgstr "Équipes auxquelles ce groupe d'organisation est actuellement attribué" @@ -12249,6 +12297,7 @@ msgstr "Non autorisé" msgid "Uncompleted" msgstr "Non complet" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "Annuler" @@ -12280,8 +12329,6 @@ msgstr "Nom inconnu" #: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Unlimited" msgstr "Illimité" @@ -12298,6 +12345,10 @@ msgstr "Délier" msgid "Unpin" msgstr "Détacher" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "Unsaved changes" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" @@ -12317,9 +12368,6 @@ msgstr "Groupe sans titre" #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx -#: apps/remix/app/components/forms/branding-preferences-form.tsx -#: apps/remix/app/components/forms/document-preferences-form.tsx -#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -12342,6 +12390,7 @@ msgstr "Mettre à jour la bannière" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Update Billing" msgstr "Mettre à jour la facturation" @@ -12369,10 +12418,6 @@ msgstr "Mettre à jour l'e-mail" msgid "Update Fields" msgstr "Mettre à jour les champs" -#: apps/remix/app/components/forms/organisation-update-form.tsx -msgid "Update organisation" -msgstr "Mettre à jour l'organisation" - #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -12408,10 +12453,6 @@ msgstr "Mettre à jour le rôle" msgid "Update Subscription Claim" msgstr "Mettre à jour la réclamation d'abonnement" -#: apps/remix/app/components/forms/team-update-form.tsx -msgid "Update team" -msgstr "Mettre à jour l'équipe" - #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx msgid "Update team email" @@ -12580,15 +12621,18 @@ msgstr "Importation en cours" msgid "URL" msgstr "URL" -#. placeholder {0}: selectedStat?.period || 'N/A' -#: apps/remix/app/components/general/organisation-usage-panel.tsx -msgid "Usage for period: {0}" -msgstr "Utilisation pour la période : {0}" - #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx msgid "Use" msgstr "Utiliser" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h" +msgstr "" + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a unique window for each rate limit" +msgstr "" + #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx msgid "Use Authenticator" @@ -12942,7 +12986,7 @@ msgstr "En attente" msgid "Waiting for others" msgstr "En attente des autres" -#: packages/lib/server-only/document/send-pending-email.ts +#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing." msgstr "En attente que d'autres terminent la signature." @@ -13490,10 +13534,18 @@ msgstr "Marque blanche, membres illimités et plus" msgid "Width:" msgstr "Largeur :" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Window" +msgstr "" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Withdrawing Consent" msgstr "Retrait du consentement" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Within limit" +msgstr "" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" msgstr "Écrivez une description à afficher sur votre profil public" @@ -13916,8 +13968,7 @@ msgstr "Vous avez été invité à rejoindre {0} sur Documenso" msgid "You have been invited to join the following organisation" msgstr "Vous avez été invité à rejoindre l'organisation suivante" -#: packages/lib/server-only/recipient/delete-envelope-recipient.ts -#: packages/lib/server-only/recipient/set-document-recipients.ts +#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts msgid "You have been removed from a document" msgstr "Vous avez été supprimé d'un document" @@ -14037,6 +14088,10 @@ msgstr "Vous avez révoqué l'accès avec succès." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "Vous avez le droit de retirer votre consentement à l'utilisation des signatures électroniques à tout moment avant de terminer le processus de signature. Pour retirer votre consentement, veuillez contacter l'expéditeur du document. Si vous ne contactez pas l'expéditeur, vous pouvez contacter <0>{SUPPORT_EMAIL} pour obtenir de l'aide. Sachez que le retrait de consentement peut retarder ou arrêter l'achèvement de la transaction ou du service associé." +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "You have unsaved changes" +msgstr "" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You have updated {memberName}." msgstr "Vous avez mis à jour {memberName}." @@ -14716,4 +14771,3 @@ msgstr "Votre code de vérification :" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/translations/it/web.po b/packages/lib/translations/it/web.po index d59511b72..e8f43c9f2 100644 --- a/packages/lib/translations/it/web.po +++ b/packages/lib/translations/it/web.po @@ -1416,8 +1416,8 @@ msgid "Add Placeholders" msgstr "Aggiungi segnaposto" #: apps/remix/app/components/general/rate-limit-array-input.tsx -msgid "Add rate limit" -msgstr "Aggiungi limite di velocità" +msgid "Add rate limit window" +msgstr "" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Add recipients" @@ -2008,6 +2008,7 @@ msgstr "Qualsiasi fonte" msgid "Any Status" msgstr "Qualsiasi stato" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "API" msgstr "API" @@ -2017,10 +2018,6 @@ msgstr "API" msgid "API key" msgstr "Chiave API" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "API rate limits" -msgstr "Limiti di velocità API" - #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "API requests" msgstr "Richieste API" @@ -2441,6 +2438,7 @@ msgstr "Logo del Marchio" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding Preferences" msgstr "Preferenze per il branding" @@ -2680,6 +2678,10 @@ msgstr "Impossibile rimuovere il firmatario" msgid "Cannot upload items after the document has been sent" msgstr "Non è possibile caricare gli elementi dopo che il documento è stato inviato" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Capabilities enabled for this organisation." +msgstr "" + #: packages/lib/constants/recipient-roles.ts msgctxt "Recipient role name" msgid "Cc" @@ -3572,6 +3574,7 @@ msgid "Currently all organisation members can access this team" msgstr "Attualmente tutti i membri dell'organizzazione possono accedere a questo team" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "Attualmente il marchio può essere configurato solo per i piani Team e superiori." @@ -4214,8 +4217,8 @@ msgstr "Documento annullato" #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts -#: packages/lib/server-only/document/delete-document.ts msgid "Document Cancelled" msgstr "Documento Annullato" @@ -4405,10 +4408,6 @@ msgstr "Preferenze Documento" msgid "Document preferences updated" msgstr "Preferenze del documento aggiornate" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Document rate limits" -msgstr "Limiti di velocità documento" - #: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document rejected" @@ -4544,6 +4543,7 @@ msgstr "Documentazione" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-nav-desktop.tsx #: apps/remix/app/components/general/app-nav-mobile.tsx +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -4993,10 +4993,6 @@ msgstr "Preferenze Email" msgid "Email preferences updated" msgstr "Preferenze email aggiornate" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Email rate limits" -msgstr "Limiti di velocità email" - #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email recipients when a pending document is deleted" msgstr "Invia un'email ai destinatari quando un documento in sospeso viene eliminato" @@ -5092,6 +5088,7 @@ msgstr "Verifica email rimossa" msgid "Email verification has been resent" msgstr "Verifica email rinviata" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -5105,16 +5102,14 @@ msgstr "Email" msgid "Embedding, 5 members included and more" msgstr "Incorporamento, 5 membri inclusi e altro" -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Empty = Unlimited, 0 = Blocked" -msgstr "Vuoto = Illimitato, 0 = Bloccato" - #: packages/ui/primitives/document-flow/add-fields.tsx msgid "Empty field" msgstr "Campo vuoto" +#: apps/remix/app/components/general/claim-limit-fields.tsx +msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." +msgstr "" + #: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx msgid "Enable" msgstr "Abilita" @@ -5234,6 +5229,10 @@ msgstr "Assicurati di utilizzare il token di embedding, e non il token API" msgid "Enter" msgstr "Inserisci" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a max request count greater than 0" +msgstr "" + #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." msgstr "Inserisci un nome per la tua nuova cartella. Le cartelle ti aiutano a organizzare i tuoi elementi." @@ -5242,6 +5241,10 @@ msgstr "Inserisci un nome per la tua nuova cartella. Le cartelle ti aiutano a or msgid "Enter a new title" msgstr "Inserisci un nuovo titolo" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a window, e.g. 5m" +msgstr "" + #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Enter claim name" msgstr "Inserisci nome richiesta" @@ -5500,6 +5503,10 @@ msgstr "Hanno firmato tutti" msgid "Everyone has signed! You will receive an email copy of the signed document." msgstr "Tutti hanno firmato! Riceverai una copia del documento firmato via email." +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Exceeded" +msgstr "" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Exceeded timeout" msgstr "Tempo scaduto" @@ -6763,6 +6770,10 @@ msgstr "Modalità chiara" msgid "Like to have your own public profile with agreements?" msgstr "Ti piacerebbe avere il tuo profilo pubblico con accordi?" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Limit reached" +msgstr "" + #: apps/remix/app/components/general/claim-limit-fields.tsx msgid "Limits" msgstr "Limiti" @@ -7068,6 +7079,10 @@ msgstr "MAU (autenticati)" msgid "Max" msgstr "" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Max requests" +msgstr "" + #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." msgstr "Dimensione massima del file: 4MB. Massimo 100 righe per caricamento. I valori vuoti utilizzeranno i valori predefiniti del modello." @@ -7114,12 +7129,12 @@ msgstr "Membro dal" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -7188,16 +7203,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Utenti attivi mensili: Utenti con almeno uno dei loro documenti completati" #: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly API quota" -msgstr "Quota API mensile" +msgid "Monthly quota" +msgstr "" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly document quota" -msgstr "Quota documenti mensile" - -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly email quota" -msgstr "Quota email mensile" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Monthly usage" +msgstr "" #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -7288,7 +7299,6 @@ msgid "Name" msgstr "Nome" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Name is required" msgstr "Nome richiesto" @@ -7296,6 +7306,10 @@ msgstr "Nome richiesto" msgid "Name Settings" msgstr "Impostazioni Nome" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Near limit" +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Need to sign documents?" msgstr "Hai bisogno di firmare documenti?" @@ -7435,6 +7449,10 @@ msgstr "Non sono richieste ulteriori azioni da parte tua in questo momento." msgid "No groups found" msgstr "Nessun gruppo trovato" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "No inherited claim" +msgstr "" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No License Configured" msgstr "Nessuna licenza configurata" @@ -7942,6 +7960,11 @@ msgstr "Originale" msgid "Otherwise, the document will be created as a draft." msgstr "Altrimenti, il documento sarà creato come bozza." +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Overlapping fields detected" +msgstr "" + #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Override organisation settings" @@ -8146,6 +8169,10 @@ msgstr "Inviti all’organizzazione in sospeso" msgid "Pending since" msgstr "In sospeso dal" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "People with access to this organisation." +msgstr "" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -8308,10 +8335,6 @@ msgstr "Si prega di inserire un nome significativo per il proprio token. Questo msgid "Please enter a number" msgstr "Inserisci un numero" -#: apps/remix/app/components/general/claim-account.tsx -msgid "Please enter a valid name." -msgstr "Per favore inserisci un nome valido." - #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Please enter a valid number" msgstr "Per favore inserisci un numero valido" @@ -8917,6 +8940,11 @@ msgstr "URL di reindirizzamento" msgid "Redirecting" msgstr "Reindirizzamento" +#. placeholder {0}: oidcProviderLabel || 'OIDC' +#: apps/remix/app/routes/_unauthenticated+/signin.tsx +msgid "Redirecting to {0}..." +msgstr "" + #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx msgid "Registration Successful" @@ -9045,6 +9073,10 @@ msgstr "Rimuovere membro dell'organizzazione" msgid "Remove Organisation Member" msgstr "Rimuovi membro dell'organizzazione" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Remove rate limit" +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Remove recipient" msgstr "Rimuovi destinatario" @@ -9192,9 +9224,7 @@ msgstr "Invia nuovamente busta" msgid "Resend verification" msgstr "Reinvia verifica" -#: apps/remix/app/components/forms/organisation-update-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx -#: apps/remix/app/components/forms/team-update-form.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Reset" msgstr "Ripristina" @@ -9237,6 +9267,10 @@ msgstr "Risolvi" msgid "Resolve payment" msgstr "Risolvere il pagamento" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Resource blocked" +msgstr "" + #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Response" msgstr "Risposta" @@ -9379,6 +9413,7 @@ msgid "Save as Template" msgstr "Salva come modello" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx msgid "Save changes" msgstr "Salva le modifiche" @@ -9812,6 +9847,10 @@ msgstr "Invio..." msgid "Sent" msgstr "Inviato" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Sent this period" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" msgstr "Sessione revocata" @@ -10219,6 +10258,11 @@ msgstr "Impostazioni del sito" msgid "Skip" msgstr "Salta" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." +msgstr "" + #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "Alcuni firmatari non hanno un campo firma assegnato. Assegna almeno 1 campo di firma a ciascun firmatario prima di procedere." @@ -10789,10 +10833,10 @@ msgid "Team URL" msgstr "URL del team" #: apps/remix/app/components/general/org-menu-switcher.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx @@ -10803,6 +10847,10 @@ msgstr "Team" msgid "Teams help you organise your work and collaborate with others. Create your first team to get started." msgstr "I team ti aiutano a organizzare il tuo lavoro e collaborare con altri. Crea il tuo primo team per iniziare." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Teams that belong to this organisation." +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Teams that this organisation group is currently assigned to" msgstr "Team a cui è attualmente assegnato questo gruppo di organizzazione" @@ -12249,6 +12297,7 @@ msgstr "Non autorizzato" msgid "Uncompleted" msgstr "Incompleto" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "Annulla" @@ -12280,8 +12329,6 @@ msgstr "Nome sconosciuto" #: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Unlimited" msgstr "Illimitato" @@ -12298,6 +12345,10 @@ msgstr "Scollega" msgid "Unpin" msgstr "Rimuovi" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "Unsaved changes" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" @@ -12317,9 +12368,6 @@ msgstr "Gruppo senza nome" #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx -#: apps/remix/app/components/forms/branding-preferences-form.tsx -#: apps/remix/app/components/forms/document-preferences-form.tsx -#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -12342,6 +12390,7 @@ msgstr "Aggiorna banner" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Update Billing" msgstr "Aggiorna fatturazione" @@ -12369,10 +12418,6 @@ msgstr "Aggiorna email" msgid "Update Fields" msgstr "Aggiorna campi" -#: apps/remix/app/components/forms/organisation-update-form.tsx -msgid "Update organisation" -msgstr "Aggiorna organizzazione" - #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -12408,10 +12453,6 @@ msgstr "Aggiorna ruolo" msgid "Update Subscription Claim" msgstr "Aggiorna reclamo di sottoscrizione" -#: apps/remix/app/components/forms/team-update-form.tsx -msgid "Update team" -msgstr "Aggiorna team" - #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx msgid "Update team email" @@ -12580,15 +12621,18 @@ msgstr "Caricamento in corso" msgid "URL" msgstr "URL" -#. placeholder {0}: selectedStat?.period || 'N/A' -#: apps/remix/app/components/general/organisation-usage-panel.tsx -msgid "Usage for period: {0}" -msgstr "Utilizzo per il periodo: {0}" - #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx msgid "Use" msgstr "Utilizza" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h" +msgstr "" + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a unique window for each rate limit" +msgstr "" + #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx msgid "Use Authenticator" @@ -12942,7 +12986,7 @@ msgstr "In attesa" msgid "Waiting for others" msgstr "In attesa di altri" -#: packages/lib/server-only/document/send-pending-email.ts +#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing." msgstr "In attesa che altri completino la firma." @@ -13490,10 +13534,18 @@ msgstr "White label, membri illimitati e altro" msgid "Width:" msgstr "Larghezza:" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Window" +msgstr "" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Withdrawing Consent" msgstr "Ritiro del consenso" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Within limit" +msgstr "" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" msgstr "Scrivi una descrizione da mostrare sul tuo profilo pubblico" @@ -13916,8 +13968,7 @@ msgstr "Sei stato invitato a unirti a {0} su Documenso" msgid "You have been invited to join the following organisation" msgstr "Sei stato invitato a unirti alla seguente organizzazione" -#: packages/lib/server-only/recipient/delete-envelope-recipient.ts -#: packages/lib/server-only/recipient/set-document-recipients.ts +#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts msgid "You have been removed from a document" msgstr "Sei stato rimosso da un documento" @@ -14037,6 +14088,10 @@ msgstr "Hai revocato con successo l'accesso." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "Hai il diritto di ritirare il tuo consenso all'uso delle firme elettroniche in qualsiasi momento prima di completare il processo di firma. Per ritirare il tuo consenso, contatta il mittente del documento. Nel caso in cui non riesci a contattare il mittente, puoi contattare <0>{SUPPORT_EMAIL} per assistenza. Sii consapevole che il ritiro del consenso potrebbe ritardare o fermare il completamento della transazione o del servizio correlato." +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "You have unsaved changes" +msgstr "" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You have updated {memberName}." msgstr "Hai aggiornato {memberName}." @@ -14716,4 +14771,3 @@ msgstr "Il tuo codice di verifica:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "tuo-dominio.com altro-dominio.com" - diff --git a/packages/lib/translations/ja/web.po b/packages/lib/translations/ja/web.po index 2c710627f..6c240d982 100644 --- a/packages/lib/translations/ja/web.po +++ b/packages/lib/translations/ja/web.po @@ -1416,8 +1416,8 @@ msgid "Add Placeholders" msgstr "プレースホルダーを追加" #: apps/remix/app/components/general/rate-limit-array-input.tsx -msgid "Add rate limit" -msgstr "レート制限を追加" +msgid "Add rate limit window" +msgstr "" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Add recipients" @@ -2008,6 +2008,7 @@ msgstr "すべてのソース" msgid "Any Status" msgstr "すべてのステータス" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "API" msgstr "API" @@ -2017,10 +2018,6 @@ msgstr "API" msgid "API key" msgstr "API キー" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "API rate limits" -msgstr "API レート制限" - #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "API requests" msgstr "API リクエスト" @@ -2441,6 +2438,7 @@ msgstr "ブランディングロゴ" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding Preferences" msgstr "ブランディング設定" @@ -2680,6 +2678,10 @@ msgstr "署名者を削除できません" msgid "Cannot upload items after the document has been sent" msgstr "ドキュメント送信後はアイテムをアップロードできません" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Capabilities enabled for this organisation." +msgstr "" + #: packages/lib/constants/recipient-roles.ts msgctxt "Recipient role name" msgid "Cc" @@ -3572,6 +3574,7 @@ msgid "Currently all organisation members can access this team" msgstr "現在、すべての組織メンバーがこのチームにアクセスできます" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "現在、ブランディングは Teams プラン以上のみ設定できます。" @@ -4214,8 +4217,8 @@ msgstr "文書は取り消されました" #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts -#: packages/lib/server-only/document/delete-document.ts msgid "Document Cancelled" msgstr "文書はキャンセルされました" @@ -4405,10 +4408,6 @@ msgstr "ドキュメント設定" msgid "Document preferences updated" msgstr "文書設定を更新しました" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Document rate limits" -msgstr "ドキュメントのレート制限" - #: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document rejected" @@ -4544,6 +4543,7 @@ msgstr "ドキュメント" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-nav-desktop.tsx #: apps/remix/app/components/general/app-nav-mobile.tsx +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -4993,10 +4993,6 @@ msgstr "メール設定" msgid "Email preferences updated" msgstr "メール設定を更新しました" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Email rate limits" -msgstr "メールのレート制限" - #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email recipients when a pending document is deleted" msgstr "保留中のドキュメントが削除されたときに受信者へメール通知する" @@ -5092,6 +5088,7 @@ msgstr "メール認証を削除しました" msgid "Email verification has been resent" msgstr "メール認証を再送しました" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -5105,16 +5102,14 @@ msgstr "メール" msgid "Embedding, 5 members included and more" msgstr "埋め込み、5 メンバー含む など" -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Empty = Unlimited, 0 = Blocked" -msgstr "空欄 = 無制限、0 = ブロックされます" - #: packages/ui/primitives/document-flow/add-fields.tsx msgid "Empty field" msgstr "空のフィールド" +#: apps/remix/app/components/general/claim-limit-fields.tsx +msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." +msgstr "" + #: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx msgid "Enable" msgstr "有効化" @@ -5234,6 +5229,10 @@ msgstr "埋め込みトークンを使用していることを確認し、API msgid "Enter" msgstr "入力してください" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a max request count greater than 0" +msgstr "" + #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." msgstr "新しいフォルダ名を入力してください。フォルダを使うとアイテムを整理できます。" @@ -5242,6 +5241,10 @@ msgstr "新しいフォルダ名を入力してください。フォルダを使 msgid "Enter a new title" msgstr "新しいタイトルを入力してください" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a window, e.g. 5m" +msgstr "" + #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Enter claim name" msgstr "クレーム名を入力" @@ -5500,6 +5503,10 @@ msgstr "全員が署名しました" msgid "Everyone has signed! You will receive an email copy of the signed document." msgstr "全員が署名しました。署名済みドキュメントのコピーがメールで送信されます。" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Exceeded" +msgstr "" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Exceeded timeout" msgstr "タイムアウトを超えました" @@ -6763,6 +6770,10 @@ msgstr "ライトモード" msgid "Like to have your own public profile with agreements?" msgstr "自分の合意書付き公開プロフィールが欲しいですか?" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Limit reached" +msgstr "" + #: apps/remix/app/components/general/claim-limit-fields.tsx msgid "Limits" msgstr "上限" @@ -7068,6 +7079,10 @@ msgstr "MAU(サインイン済み)" msgid "Max" msgstr "最大" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Max requests" +msgstr "" + #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." msgstr "最大ファイルサイズ: 4MB。アップロードあたり最大 100 行。空の値はテンプレートのデフォルトが使用されます。" @@ -7114,12 +7129,12 @@ msgstr "メンバー登録日" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -7188,16 +7203,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "月間アクティブユーザー:1 つ以上の文書が完了したユーザー" #: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly API quota" -msgstr "月間 API クォータ" +msgid "Monthly quota" +msgstr "" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly document quota" -msgstr "月間ドキュメントクォータ" - -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly email quota" -msgstr "月間メールクォータ" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Monthly usage" +msgstr "" #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -7288,7 +7299,6 @@ msgid "Name" msgstr "名前" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Name is required" msgstr "名前は必須です" @@ -7296,6 +7306,10 @@ msgstr "名前は必須です" msgid "Name Settings" msgstr "名前の設定" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Near limit" +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Need to sign documents?" msgstr "文書への署名が必要ですか?" @@ -7435,6 +7449,10 @@ msgstr "現在、お客様が行う必要のある操作はありません。" msgid "No groups found" msgstr "グループが見つかりません" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "No inherited claim" +msgstr "" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No License Configured" msgstr "ライセンスが設定されていません" @@ -7942,6 +7960,11 @@ msgstr "オリジナル" msgid "Otherwise, the document will be created as a draft." msgstr "チェックを入れない場合、文書は下書きとして作成されます。" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Overlapping fields detected" +msgstr "" + #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Override organisation settings" @@ -8146,6 +8169,10 @@ msgstr "保留中の組織招待" msgid "Pending since" msgstr "保留開始日時" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "People with access to this organisation." +msgstr "" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -8308,10 +8335,6 @@ msgstr "トークンの用途が分かる名前を入力してください。後 msgid "Please enter a number" msgstr "数値を入力してください" -#: apps/remix/app/components/general/claim-account.tsx -msgid "Please enter a valid name." -msgstr "有効な名前を入力してください。" - #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Please enter a valid number" msgstr "有効な数値を入力してください" @@ -8917,6 +8940,11 @@ msgstr "リダイレクト URL" msgid "Redirecting" msgstr "リダイレクト中" +#. placeholder {0}: oidcProviderLabel || 'OIDC' +#: apps/remix/app/routes/_unauthenticated+/signin.tsx +msgid "Redirecting to {0}..." +msgstr "" + #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx msgid "Registration Successful" @@ -9045,6 +9073,10 @@ msgstr "組織メンバーを削除" msgid "Remove Organisation Member" msgstr "組織メンバーを削除" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Remove rate limit" +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Remove recipient" msgstr "受信者を削除" @@ -9192,9 +9224,7 @@ msgstr "封筒を再送信" msgid "Resend verification" msgstr "認証を再送" -#: apps/remix/app/components/forms/organisation-update-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx -#: apps/remix/app/components/forms/team-update-form.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Reset" msgstr "リセット" @@ -9237,6 +9267,10 @@ msgstr "解決" msgid "Resolve payment" msgstr "支払いを解決" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Resource blocked" +msgstr "" + #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Response" msgstr "レスポンス" @@ -9379,6 +9413,7 @@ msgid "Save as Template" msgstr "テンプレートとして保存" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx msgid "Save changes" msgstr "変更を保存" @@ -9812,6 +9847,10 @@ msgstr "送信中..." msgid "Sent" msgstr "送信日時" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Sent this period" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" msgstr "セッションを取り消しました" @@ -10219,6 +10258,11 @@ msgstr "サイト設定" msgid "Skip" msgstr "スキップ" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." +msgstr "" + #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "一部の署名者に署名フィールドが割り当てられていません。続行する前に、各署名者に少なくとも 1 つの署名フィールドを割り当ててください。" @@ -10789,10 +10833,10 @@ msgid "Team URL" msgstr "チーム URL" #: apps/remix/app/components/general/org-menu-switcher.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx @@ -10803,6 +10847,10 @@ msgstr "チーム" msgid "Teams help you organise your work and collaborate with others. Create your first team to get started." msgstr "チームは作業を整理し、他のメンバーとコラボレーションするのに役立ちます。最初のチームを作成して始めましょう。" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Teams that belong to this organisation." +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Teams that this organisation group is currently assigned to" msgstr "この組織グループが現在割り当てられているチーム" @@ -12249,6 +12297,7 @@ msgstr "権限がありません" msgid "Uncompleted" msgstr "未完了" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "元に戻す" @@ -12280,8 +12329,6 @@ msgstr "不明な名前" #: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Unlimited" msgstr "無制限" @@ -12298,6 +12345,10 @@ msgstr "リンク解除" msgid "Unpin" msgstr "ピン留めを解除" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "Unsaved changes" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" @@ -12317,9 +12368,6 @@ msgstr "無題のグループ" #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx -#: apps/remix/app/components/forms/branding-preferences-form.tsx -#: apps/remix/app/components/forms/document-preferences-form.tsx -#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -12342,6 +12390,7 @@ msgstr "バナーを更新" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Update Billing" msgstr "請求情報を更新" @@ -12369,10 +12418,6 @@ msgstr "メールを更新" msgid "Update Fields" msgstr "フィールドを更新" -#: apps/remix/app/components/forms/organisation-update-form.tsx -msgid "Update organisation" -msgstr "組織を更新" - #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -12408,10 +12453,6 @@ msgstr "役割を更新" msgid "Update Subscription Claim" msgstr "サブスクリプションクレームを更新" -#: apps/remix/app/components/forms/team-update-form.tsx -msgid "Update team" -msgstr "チームを更新" - #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx msgid "Update team email" @@ -12580,15 +12621,18 @@ msgstr "アップロード中" msgid "URL" msgstr "URL" -#. placeholder {0}: selectedStat?.period || 'N/A' -#: apps/remix/app/components/general/organisation-usage-panel.tsx -msgid "Usage for period: {0}" -msgstr "期間内の利用状況: {0}" - #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx msgid "Use" msgstr "使用" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h" +msgstr "" + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a unique window for each rate limit" +msgstr "" + #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx msgid "Use Authenticator" @@ -12942,7 +12986,7 @@ msgstr "保留中" msgid "Waiting for others" msgstr "他の人の完了待ち" -#: packages/lib/server-only/document/send-pending-email.ts +#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing." msgstr "他の署名者による署名完了を待っています。" @@ -13490,10 +13534,18 @@ msgstr "ホワイトラベリング、メンバー無制限など" msgid "Width:" msgstr "幅:" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Window" +msgstr "" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Withdrawing Consent" msgstr "同意の撤回" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Within limit" +msgstr "" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" msgstr "公開プロフィールに表示する説明文を入力してください" @@ -13916,8 +13968,7 @@ msgstr "Documenso で {0} に参加するよう招待されています" msgid "You have been invited to join the following organisation" msgstr "次の組織に参加するよう招待されています。" -#: packages/lib/server-only/recipient/delete-envelope-recipient.ts -#: packages/lib/server-only/recipient/set-document-recipients.ts +#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts msgid "You have been removed from a document" msgstr "ドキュメントから削除されました" @@ -14037,6 +14088,10 @@ msgstr "アクセスを正常に取り消しました。" msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "署名プロセスを完了する前であれば、電子署名の利用に対する同意をいつでも撤回する権利があります。同意を撤回するには、文書の送信者に連絡してください。送信者に連絡できない場合は、<0>{SUPPORT_EMAIL} までお問い合わせください。同意を撤回すると、関連する取引やサービスの完了が遅延または中止される可能性がある点にご注意ください。" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "You have unsaved changes" +msgstr "" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You have updated {memberName}." msgstr "{memberName} を更新しました。" @@ -14716,4 +14771,3 @@ msgstr "認証コード:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/translations/ko/web.po b/packages/lib/translations/ko/web.po index 802fe31f9..6355a8098 100644 --- a/packages/lib/translations/ko/web.po +++ b/packages/lib/translations/ko/web.po @@ -1416,8 +1416,8 @@ msgid "Add Placeholders" msgstr "플레이스홀더 추가" #: apps/remix/app/components/general/rate-limit-array-input.tsx -msgid "Add rate limit" -msgstr "요청 한도 추가" +msgid "Add rate limit window" +msgstr "" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Add recipients" @@ -2008,6 +2008,7 @@ msgstr "모든 소스" msgid "Any Status" msgstr "모든 상태" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "API" msgstr "API" @@ -2017,10 +2018,6 @@ msgstr "API" msgid "API key" msgstr "API 키" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "API rate limits" -msgstr "API 요청 한도" - #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "API requests" msgstr "API 요청" @@ -2441,6 +2438,7 @@ msgstr "브랜딩 로고" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding Preferences" msgstr "브랜딩 환경설정" @@ -2680,6 +2678,10 @@ msgstr "서명자를 제거할 수 없습니다." msgid "Cannot upload items after the document has been sent" msgstr "문서를 전송한 이후에는 항목을 업로드할 수 없습니다." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Capabilities enabled for this organisation." +msgstr "" + #: packages/lib/constants/recipient-roles.ts msgctxt "Recipient role name" msgid "Cc" @@ -3572,6 +3574,7 @@ msgid "Currently all organisation members can access this team" msgstr "현재 모든 조직 구성원이 이 팀에 접근할 수 있습니다." #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "브랜딩은 현재 Teams 요금제 이상에서만 구성할 수 있습니다." @@ -4214,8 +4217,8 @@ msgstr "문서가 취소되었습니다" #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts -#: packages/lib/server-only/document/delete-document.ts msgid "Document Cancelled" msgstr "문서가 취소됨" @@ -4405,10 +4408,6 @@ msgstr "문서 기본 설정" msgid "Document preferences updated" msgstr "문서 환경설정이 업데이트되었습니다" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Document rate limits" -msgstr "문서 요청 한도" - #: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document rejected" @@ -4544,6 +4543,7 @@ msgstr "문서" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-nav-desktop.tsx #: apps/remix/app/components/general/app-nav-mobile.tsx +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -4993,10 +4993,6 @@ msgstr "이메일 기본 설정" msgid "Email preferences updated" msgstr "이메일 기본 설정이 업데이트되었습니다." -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Email rate limits" -msgstr "이메일 요청 한도" - #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email recipients when a pending document is deleted" msgstr "보류 중인 문서가 삭제되면 수신자에게 이메일 보내기" @@ -5092,6 +5088,7 @@ msgstr "이메일 인증이 제거되었습니다" msgid "Email verification has been resent" msgstr "이메일 인증이 다시 전송되었습니다" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -5105,16 +5102,14 @@ msgstr "이메일" msgid "Embedding, 5 members included and more" msgstr "임베딩, 5명의 구성원 포함 등" -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Empty = Unlimited, 0 = Blocked" -msgstr "비워 두기 = 무제한, 0 = 차단됨" - #: packages/ui/primitives/document-flow/add-fields.tsx msgid "Empty field" msgstr "빈 필드" +#: apps/remix/app/components/general/claim-limit-fields.tsx +msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." +msgstr "" + #: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx msgid "Enable" msgstr "활성화" @@ -5234,6 +5229,10 @@ msgstr "임베딩 토큰이 아닌 API 토큰을 사용하고 있지 않은지 msgid "Enter" msgstr "입력하세요" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a max request count greater than 0" +msgstr "" + #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." msgstr "새 폴더 이름을 입력하세요. 폴더는 항목을 정리하는 데 도움이 됩니다." @@ -5242,6 +5241,10 @@ msgstr "새 폴더 이름을 입력하세요. 폴더는 항목을 정리하는 msgid "Enter a new title" msgstr "새 제목을 입력하세요." +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a window, e.g. 5m" +msgstr "" + #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Enter claim name" msgstr "클레임 이름 입력" @@ -5500,6 +5503,10 @@ msgstr "모든 사람이 서명했습니다" msgid "Everyone has signed! You will receive an email copy of the signed document." msgstr "모두 서명했습니다! 서명된 문서의 사본이 이메일로 전송됩니다." +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Exceeded" +msgstr "" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Exceeded timeout" msgstr "시간 초과됨" @@ -6763,6 +6770,10 @@ msgstr "라이트 모드" msgid "Like to have your own public profile with agreements?" msgstr "계약이 포함된 나만의 공개 프로필을 원하시나요?" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Limit reached" +msgstr "" + #: apps/remix/app/components/general/claim-limit-fields.tsx msgid "Limits" msgstr "제한" @@ -7068,6 +7079,10 @@ msgstr "MAU(로그인 기준)" msgid "Max" msgstr "최대" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Max requests" +msgstr "" + #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." msgstr "최대 파일 크기: 4MB. 업로드당 최대 100행. 비어 있는 값은 템플릿 기본값이 사용됩니다." @@ -7114,12 +7129,12 @@ msgstr "가입일" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -7188,16 +7203,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "월간 활성 사용자: 문서가 하나 이상 완료된 사용자" #: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly API quota" -msgstr "월간 API 할당량" +msgid "Monthly quota" +msgstr "" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly document quota" -msgstr "월간 문서 할당량" - -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly email quota" -msgstr "월간 이메일 할당량" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Monthly usage" +msgstr "" #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -7288,7 +7299,6 @@ msgid "Name" msgstr "이름" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Name is required" msgstr "이름은 필수 항목입니다." @@ -7296,6 +7306,10 @@ msgstr "이름은 필수 항목입니다." msgid "Name Settings" msgstr "이름 설정" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Near limit" +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Need to sign documents?" msgstr "문서에 서명이 필요하신가요?" @@ -7435,6 +7449,10 @@ msgstr "현재 추가로 수행해야 할 작업은 없습니다." msgid "No groups found" msgstr "그룹을 찾을 수 없습니다." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "No inherited claim" +msgstr "" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No License Configured" msgstr "라이선스가 구성되지 않았습니다" @@ -7942,6 +7960,11 @@ msgstr "원본" msgid "Otherwise, the document will be created as a draft." msgstr "그렇지 않으면 문서는 초안으로 생성됩니다." +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Overlapping fields detected" +msgstr "" + #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Override organisation settings" @@ -8146,6 +8169,10 @@ msgstr "보류 중인 조직 초대" msgid "Pending since" msgstr "다음 시점부터 보류 중" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "People with access to this organisation." +msgstr "" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -8308,10 +8335,6 @@ msgstr "토큰을 나중에 식별할 수 있도록 의미 있는 이름을 입 msgid "Please enter a number" msgstr "숫자를 입력하세요" -#: apps/remix/app/components/general/claim-account.tsx -msgid "Please enter a valid name." -msgstr "올바른 이름을 입력해 주세요." - #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Please enter a valid number" msgstr "올바른 숫자를 입력하세요" @@ -8917,6 +8940,11 @@ msgstr "리디렉션 URL" msgid "Redirecting" msgstr "리디렉션 중" +#. placeholder {0}: oidcProviderLabel || 'OIDC' +#: apps/remix/app/routes/_unauthenticated+/signin.tsx +msgid "Redirecting to {0}..." +msgstr "" + #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx msgid "Registration Successful" @@ -9045,6 +9073,10 @@ msgstr "조직 구성원 제거" msgid "Remove Organisation Member" msgstr "조직 구성원 제거" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Remove rate limit" +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Remove recipient" msgstr "수신자 제거" @@ -9192,9 +9224,7 @@ msgstr "봉투 다시 보내기" msgid "Resend verification" msgstr "인증 다시 보내기" -#: apps/remix/app/components/forms/organisation-update-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx -#: apps/remix/app/components/forms/team-update-form.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Reset" msgstr "초기화" @@ -9237,6 +9267,10 @@ msgstr "해결" msgid "Resolve payment" msgstr "결제 해결" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Resource blocked" +msgstr "" + #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Response" msgstr "응답" @@ -9379,6 +9413,7 @@ msgid "Save as Template" msgstr "템플릿으로 저장" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx msgid "Save changes" msgstr "변경 사항 저장" @@ -9812,6 +9847,10 @@ msgstr "전송 중..." msgid "Sent" msgstr "발송됨" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Sent this period" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" msgstr "세션이 해지되었습니다." @@ -10219,6 +10258,11 @@ msgstr "사이트 설정" msgid "Skip" msgstr "건너뛰기" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." +msgstr "" + #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "일부 서명자에게 서명 필드가 할당되지 않았습니다. 진행하기 전에 각 서명자에게 최소 1개 이상의 서명 필드를 할당해 주세요." @@ -10789,10 +10833,10 @@ msgid "Team URL" msgstr "팀 URL" #: apps/remix/app/components/general/org-menu-switcher.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx @@ -10803,6 +10847,10 @@ msgstr "팀" msgid "Teams help you organise your work and collaborate with others. Create your first team to get started." msgstr "팀은 작업을 조직하고 다른 사람과 협업하는 데 도움이 됩니다. 첫 번째 팀을 생성하여 시작하세요." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Teams that belong to this organisation." +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Teams that this organisation group is currently assigned to" msgstr "이 조직 그룹이 현재 할당된 팀" @@ -12249,6 +12297,7 @@ msgstr "권한이 없습니다" msgid "Uncompleted" msgstr "미완료" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "실행 취소" @@ -12280,8 +12329,6 @@ msgstr "이름을 알 수 없음" #: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Unlimited" msgstr "무제한" @@ -12298,6 +12345,10 @@ msgstr "연결 해제" msgid "Unpin" msgstr "고정 해제" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "Unsaved changes" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" @@ -12317,9 +12368,6 @@ msgstr "제목 없는 그룹" #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx -#: apps/remix/app/components/forms/branding-preferences-form.tsx -#: apps/remix/app/components/forms/document-preferences-form.tsx -#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -12342,6 +12390,7 @@ msgstr "배너 업데이트" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Update Billing" msgstr "결제 정보 업데이트" @@ -12369,10 +12418,6 @@ msgstr "이메일 업데이트" msgid "Update Fields" msgstr "필드 업데이트" -#: apps/remix/app/components/forms/organisation-update-form.tsx -msgid "Update organisation" -msgstr "조직 업데이트" - #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -12408,10 +12453,6 @@ msgstr "역할 업데이트" msgid "Update Subscription Claim" msgstr "구독 클레임 업데이트" -#: apps/remix/app/components/forms/team-update-form.tsx -msgid "Update team" -msgstr "팀 업데이트" - #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx msgid "Update team email" @@ -12580,15 +12621,18 @@ msgstr "업로드 중" msgid "URL" msgstr "URL" -#. placeholder {0}: selectedStat?.period || 'N/A' -#: apps/remix/app/components/general/organisation-usage-panel.tsx -msgid "Usage for period: {0}" -msgstr "기간별 사용량: {0}" - #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx msgid "Use" msgstr "사용" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h" +msgstr "" + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a unique window for each rate limit" +msgstr "" + #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx msgid "Use Authenticator" @@ -12942,7 +12986,7 @@ msgstr "대기 중" msgid "Waiting for others" msgstr "다른 사람을 기다리는 중" -#: packages/lib/server-only/document/send-pending-email.ts +#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing." msgstr "다른 서명자들이 이 문서에 서명 완료하기를 기다리는 중입니다." @@ -13490,10 +13534,18 @@ msgstr "화이트라벨, 무제한 구성원 등" msgid "Width:" msgstr "너비:" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Window" +msgstr "" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Withdrawing Consent" msgstr "동의 철회" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Within limit" +msgstr "" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" msgstr "공개 프로필에 표시될 설명을 작성하세요." @@ -13916,8 +13968,7 @@ msgstr "Documenso에서 {0} 조직에 초대되었습니다." msgid "You have been invited to join the following organisation" msgstr "다음 조직에 참여하라는 초대를 받았습니다." -#: packages/lib/server-only/recipient/delete-envelope-recipient.ts -#: packages/lib/server-only/recipient/set-document-recipients.ts +#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts msgid "You have been removed from a document" msgstr "문서에서 제거되었습니다." @@ -14037,6 +14088,10 @@ msgstr "접근 권한을 성공적으로 철회했습니다." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "전자 서명을 완료하기 전 언제든지 전자 서명 사용에 대한 동의를 철회할 권리가 있습니다. 동의를 철회하려면 문서 발송자에게 문의해 주세요. 발송자에게 연락할 수 없는 경우 <0>{SUPPORT_EMAIL}로 도움을 요청해 주세요. 동의를 철회하면 관련 거래나 서비스가 지연되거나 중단될 수 있습니다." +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "You have unsaved changes" +msgstr "" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You have updated {memberName}." msgstr "{memberName}을(를) 업데이트했습니다." @@ -14716,4 +14771,3 @@ msgstr "인증 코드:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/translations/nl/web.po b/packages/lib/translations/nl/web.po index 49953db32..ea27be4b1 100644 --- a/packages/lib/translations/nl/web.po +++ b/packages/lib/translations/nl/web.po @@ -1416,8 +1416,8 @@ msgid "Add Placeholders" msgstr "Placeholders toevoegen" #: apps/remix/app/components/general/rate-limit-array-input.tsx -msgid "Add rate limit" -msgstr "Snelheidslimiet toevoegen" +msgid "Add rate limit window" +msgstr "" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Add recipients" @@ -2008,6 +2008,7 @@ msgstr "Elke bron" msgid "Any Status" msgstr "Elke status" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "API" msgstr "API" @@ -2017,10 +2018,6 @@ msgstr "API" msgid "API key" msgstr "API-sleutel" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "API rate limits" -msgstr "API-snelheidslimieten" - #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "API requests" msgstr "API-verzoeken" @@ -2441,6 +2438,7 @@ msgstr "Branding-logo" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding Preferences" msgstr "Brandingvoorkeuren" @@ -2680,6 +2678,10 @@ msgstr "Ondertekenaar kan niet worden verwijderd" msgid "Cannot upload items after the document has been sent" msgstr "Items kunnen niet worden geüpload nadat het document is verzonden" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Capabilities enabled for this organisation." +msgstr "" + #: packages/lib/constants/recipient-roles.ts msgctxt "Recipient role name" msgid "Cc" @@ -3572,6 +3574,7 @@ msgid "Currently all organisation members can access this team" msgstr "Momenteel hebben alle organisatieleden toegang tot dit team" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "Branding kan momenteel alleen worden geconfigureerd voor Teams- en hogere abonnementen." @@ -4214,8 +4217,8 @@ msgstr "Document geannuleerd" #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts -#: packages/lib/server-only/document/delete-document.ts msgid "Document Cancelled" msgstr "Document geannuleerd" @@ -4405,10 +4408,6 @@ msgstr "Documentvoorkeuren" msgid "Document preferences updated" msgstr "Documentvoorkeuren bijgewerkt" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Document rate limits" -msgstr "Documentsnelheidslimieten" - #: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document rejected" @@ -4544,6 +4543,7 @@ msgstr "Documentatie" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-nav-desktop.tsx #: apps/remix/app/components/general/app-nav-mobile.tsx +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -4993,10 +4993,6 @@ msgstr "E-mailvoorkeuren" msgid "Email preferences updated" msgstr "E-mailvoorkeuren bijgewerkt" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Email rate limits" -msgstr "E-mailsnelheidslimieten" - #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email recipients when a pending document is deleted" msgstr "E-mail ontvangers wanneer een in behandeling zijnd document wordt verwijderd" @@ -5092,6 +5088,7 @@ msgstr "E‑mailverificatie is verwijderd" msgid "Email verification has been resent" msgstr "E‑mailverificatie is opnieuw verzonden" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -5105,16 +5102,14 @@ msgstr "E-mails" msgid "Embedding, 5 members included and more" msgstr "Inbedding, 5 leden inbegrepen en meer" -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Empty = Unlimited, 0 = Blocked" -msgstr "Leeg = Onbeperkt, 0 = Geblokkeerd" - #: packages/ui/primitives/document-flow/add-fields.tsx msgid "Empty field" msgstr "Leeg veld" +#: apps/remix/app/components/general/claim-limit-fields.tsx +msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." +msgstr "" + #: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx msgid "Enable" msgstr "Inschakelen" @@ -5234,6 +5229,10 @@ msgstr "Zorg ervoor dat je het embedding-token gebruikt en niet het API-token" msgid "Enter" msgstr "Invoeren" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a max request count greater than 0" +msgstr "" + #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." msgstr "Voer een naam in voor je nieuwe map. Mappen helpen je je items te organiseren." @@ -5242,6 +5241,10 @@ msgstr "Voer een naam in voor je nieuwe map. Mappen helpen je je items te organi msgid "Enter a new title" msgstr "Voer een nieuwe titel in" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a window, e.g. 5m" +msgstr "" + #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Enter claim name" msgstr "Voer claimnaam in" @@ -5500,6 +5503,10 @@ msgstr "Iedereen heeft getekend" msgid "Everyone has signed! You will receive an email copy of the signed document." msgstr "Iedereen heeft ondertekend! U ontvangt een kopie van het ondertekende document per e-mail." +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Exceeded" +msgstr "" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Exceeded timeout" msgstr "Time‑out overschreden" @@ -6763,6 +6770,10 @@ msgstr "Lichte modus" msgid "Like to have your own public profile with agreements?" msgstr "Wil je een eigen openbaar profiel met overeenkomsten?" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Limit reached" +msgstr "" + #: apps/remix/app/components/general/claim-limit-fields.tsx msgid "Limits" msgstr "Limieten" @@ -7068,6 +7079,10 @@ msgstr "MAU (ingelogd)" msgid "Max" msgstr "Max" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Max requests" +msgstr "" + #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." msgstr "Maximale bestandsgrootte: 4 MB. Maximaal 100 rijen per upload. Lege waarden gebruiken de standaardwaarden van de sjabloon." @@ -7114,12 +7129,12 @@ msgstr "Lid sinds" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -7188,16 +7203,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Maandelijks actieve gebruikers: gebruikers van wie ten minste één document is voltooid" #: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly API quota" -msgstr "Maandelijkse API-limiet" +msgid "Monthly quota" +msgstr "" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly document quota" -msgstr "Maandelijkse documentlimiet" - -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly email quota" -msgstr "Maandelijkse e-maillimiet" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Monthly usage" +msgstr "" #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -7288,7 +7299,6 @@ msgid "Name" msgstr "Naam" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Name is required" msgstr "Naam is verplicht" @@ -7296,6 +7306,10 @@ msgstr "Naam is verplicht" msgid "Name Settings" msgstr "Naam-instellingen" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Near limit" +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Need to sign documents?" msgstr "Moet je documenten ondertekenen?" @@ -7435,6 +7449,10 @@ msgstr "Er is op dit moment geen verdere actie van jou vereist." msgid "No groups found" msgstr "Geen groepen gevonden" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "No inherited claim" +msgstr "" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No License Configured" msgstr "Geen licentie geconfigureerd" @@ -7942,6 +7960,11 @@ msgstr "Origineel" msgid "Otherwise, the document will be created as a draft." msgstr "Anders wordt het document als concept aangemaakt." +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Overlapping fields detected" +msgstr "" + #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Override organisation settings" @@ -8146,6 +8169,10 @@ msgstr "Openstaande organisatie-uitnodigingen" msgid "Pending since" msgstr "In behandeling sinds" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "People with access to this organisation." +msgstr "" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -8308,10 +8335,6 @@ msgstr "Voer een betekenisvolle naam in voor je token. Hiermee kun je het later msgid "Please enter a number" msgstr "Voer een nummer in" -#: apps/remix/app/components/general/claim-account.tsx -msgid "Please enter a valid name." -msgstr "Voer een geldige naam in." - #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Please enter a valid number" msgstr "Voer een geldig nummer in" @@ -8917,6 +8940,11 @@ msgstr "Redirect-URL" msgid "Redirecting" msgstr "Doorsturen" +#. placeholder {0}: oidcProviderLabel || 'OIDC' +#: apps/remix/app/routes/_unauthenticated+/signin.tsx +msgid "Redirecting to {0}..." +msgstr "" + #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx msgid "Registration Successful" @@ -9045,6 +9073,10 @@ msgstr "Organisatielid verwijderen" msgid "Remove Organisation Member" msgstr "Organisatielid verwijderen" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Remove rate limit" +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Remove recipient" msgstr "Ontvanger verwijderen" @@ -9192,9 +9224,7 @@ msgstr "Envelope opnieuw verzenden" msgid "Resend verification" msgstr "Verificatie opnieuw verzenden" -#: apps/remix/app/components/forms/organisation-update-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx -#: apps/remix/app/components/forms/team-update-form.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Reset" msgstr "Resetten" @@ -9237,6 +9267,10 @@ msgstr "Oplossen" msgid "Resolve payment" msgstr "Betaling oplossen" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Resource blocked" +msgstr "" + #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Response" msgstr "Antwoord" @@ -9379,6 +9413,7 @@ msgid "Save as Template" msgstr "Opslaan als sjabloon" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx msgid "Save changes" msgstr "Wijzigingen opslaan" @@ -9812,6 +9847,10 @@ msgstr "Verzenden..." msgid "Sent" msgstr "Verzonden" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Sent this period" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" msgstr "Sessie ingetrokken" @@ -10219,6 +10258,11 @@ msgstr "Site‑instellingen" msgid "Skip" msgstr "Overslaan" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." +msgstr "" + #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "Sommige ondertekenaars hebben geen handtekeningveld toegewezen gekregen. Wijs ten minste 1 handtekeningveld toe aan elke ondertekenaar voordat je doorgaat." @@ -10789,10 +10833,10 @@ msgid "Team URL" msgstr "Team‑URL" #: apps/remix/app/components/general/org-menu-switcher.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx @@ -10803,6 +10847,10 @@ msgstr "Teams" msgid "Teams help you organise your work and collaborate with others. Create your first team to get started." msgstr "Teams helpen je je werk te organiseren en samen te werken met anderen. Maak je eerste team aan om te beginnen." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Teams that belong to this organisation." +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Teams that this organisation group is currently assigned to" msgstr "Teams waaraan deze organisatiegroep momenteel is toegewezen" @@ -12249,6 +12297,7 @@ msgstr "Niet gemachtigd" msgid "Uncompleted" msgstr "Onvoltooid" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "Ongedaan maken" @@ -12280,8 +12329,6 @@ msgstr "Onbekende naam" #: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Unlimited" msgstr "Onbeperkt" @@ -12298,6 +12345,10 @@ msgstr "Ontkoppelen" msgid "Unpin" msgstr "Losmaken" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "Unsaved changes" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" @@ -12317,9 +12368,6 @@ msgstr "Naamloze groep" #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx -#: apps/remix/app/components/forms/branding-preferences-form.tsx -#: apps/remix/app/components/forms/document-preferences-form.tsx -#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -12342,6 +12390,7 @@ msgstr "Banner bijwerken" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Update Billing" msgstr "Facturering bijwerken" @@ -12369,10 +12418,6 @@ msgstr "E-mail bijwerken" msgid "Update Fields" msgstr "Velden bijwerken" -#: apps/remix/app/components/forms/organisation-update-form.tsx -msgid "Update organisation" -msgstr "Organisatie bijwerken" - #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -12408,10 +12453,6 @@ msgstr "Rol bijwerken" msgid "Update Subscription Claim" msgstr "Abonnementsclaim bijwerken" -#: apps/remix/app/components/forms/team-update-form.tsx -msgid "Update team" -msgstr "Team bijwerken" - #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx msgid "Update team email" @@ -12580,15 +12621,18 @@ msgstr "Uploaden" msgid "URL" msgstr "URL" -#. placeholder {0}: selectedStat?.period || 'N/A' -#: apps/remix/app/components/general/organisation-usage-panel.tsx -msgid "Usage for period: {0}" -msgstr "Gebruik voor periode: {0}" - #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx msgid "Use" msgstr "Gebruiken" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h" +msgstr "" + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a unique window for each rate limit" +msgstr "" + #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx msgid "Use Authenticator" @@ -12942,7 +12986,7 @@ msgstr "Wachten" msgid "Waiting for others" msgstr "Wachten op anderen" -#: packages/lib/server-only/document/send-pending-email.ts +#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing." msgstr "Wachten tot anderen het ondertekenen hebben voltooid." @@ -13490,10 +13534,18 @@ msgstr "Whitelabeling, onbeperkte leden en meer" msgid "Width:" msgstr "Breedte:" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Window" +msgstr "" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Withdrawing Consent" msgstr "Toestemming intrekken" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Within limit" +msgstr "" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" msgstr "Schrijf een beschrijving die op je openbare profiel wordt weergegeven" @@ -13916,8 +13968,7 @@ msgstr "Je bent uitgenodigd om {0} op Documenso te joinen" msgid "You have been invited to join the following organisation" msgstr "Je bent uitgenodigd om lid te worden van de volgende organisatie" -#: packages/lib/server-only/recipient/delete-envelope-recipient.ts -#: packages/lib/server-only/recipient/set-document-recipients.ts +#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts msgid "You have been removed from a document" msgstr "Je bent verwijderd uit een document" @@ -14037,6 +14088,10 @@ msgstr "Je hebt de toegang succesvol ingetrokken." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "Je hebt het recht je toestemming voor het gebruik van elektronische handtekeningen op elk moment vóór voltooiing van het ondertekeningsproces in te trekken. Neem hiervoor contact op met de verzender van het document. Als dat niet lukt, kun je contact opnemen met <0>{SUPPORT_EMAIL} voor hulp. Houd er rekening mee dat het intrekken van toestemming de voltooiing van de betreffende transactie of dienst kan vertragen of stopzetten." +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "You have unsaved changes" +msgstr "" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You have updated {memberName}." msgstr "Je hebt {memberName} bijgewerkt." @@ -14716,4 +14771,3 @@ msgstr "Uw verificatiecode:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/translations/pl/web.po b/packages/lib/translations/pl/web.po index 5fbfa727c..523e75fc2 100644 --- a/packages/lib/translations/pl/web.po +++ b/packages/lib/translations/pl/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: pl\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-06-22 13:53\n" +"PO-Revision-Date: 2026-06-23 13:05\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" @@ -31,7 +31,7 @@ msgstr "Adres „{0}” nie jest prawidłowym adresem e-mail." #. placeholder {1}: timezone || '' #: apps/remix/app/components/general/document-signing/document-signing-date-field.tsx msgid "\"{0}\" will appear on the document as it has a timezone of \"{1}\"." -msgstr "W dokumencie, z racji strefy czasowej „{1}”, pojawi się wartość „{0}”." +msgstr "Ze względu na strefę czasową „{1}”, w dokumencie pojawi się wartość „{0}”." #: packages/email/template-components/template-document-super-delete.tsx msgid "\"{documentName}\" has been deleted by an admin." @@ -51,7 +51,7 @@ msgstr "Użytkownik „{placeholderEmail}” z zespołu „Zespół X” zaprosi #: apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx msgid "\"{title}\" has been successfully cancelled" -msgstr "Dokument „{title}” został pomyślnie anulowany" +msgstr "Dokument „{title}” został anulowany" #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx msgid "\"{title}\" has been successfully deleted" @@ -69,7 +69,7 @@ msgstr "„Zespół X” zaprosił Cię do podpisania dokumentu „ABC”." #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "(line {0})" -msgstr "(wiersz {0})" +msgstr "(linia {0})" #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -104,13 +104,13 @@ msgstr "{0, plural, one {Pozostał # znak} few {Pozostały # znaki} many {Pozost #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "{0, plural, one {# CSS rule was dropped during sanitisation.} other {# CSS rules were dropped during sanitisation.}}" -msgstr "{0, plural, one {# reguła CSS została odrzucona podczas oczyszczania.} few {# reguły CSS zostały odrzucone podczas oczyszczania.} many {# reguł CSS zostało odrzuconych podczas oczyszczania.} other {# reguły CSS zostały odrzucone podczas oczyszczania.}}" +msgstr "{0, plural, one {# reguła CSS została usunięta podczas oczyszczenia kodu.} few {# reguły CSS zostały usunięte podczas oczyszczenia kodu.} many {# reguł CSS zostało usuniętych podczas oczyszczenia kodu.} other {# reguły CSS zostały usunięte podczas oczyszczenia kodu.}}" #. placeholder {0}: result.cancelledCount #. placeholder {1}: result.failedIds.length #: apps/remix/app/components/dialogs/envelopes-bulk-cancel-dialog.tsx msgid "{0, plural, one {# document cancelled.} other {# documents cancelled.}} {1, plural, one {# document could not be cancelled.} other {# documents could not be cancelled.}}" -msgstr "{0, plural, one {Anulowano # dokument.} few {Anulowano # dokumenty.} many {Anulowano # dokumentów.} other {Anulowano # dokumentów.}} {1, plural, one {Nie udało się anulować # dokumentu.} few {Nie udało się anulować # dokumentów.} many {Nie udało się anulować # dokumentów.} other {Nie udało się anulować # dokumentów.}}" +msgstr "{0, plural, one {Anulowano # dokument.} few {Anulowano # dokumenty.} many {Anulowano # dokumentów.} other {Anulowano # dokumentów.}} {1, plural, one {Nie można było anulować # dokumentu.} few {Nie można było anulować # dokumentów.} many {Nie można było anulować # dokumentów.} other {Nie można było anulować # dokumentów.}}" #. placeholder {0}: result.cancelledCount #: apps/remix/app/components/dialogs/envelopes-bulk-cancel-dialog.tsx @@ -184,12 +184,12 @@ msgstr "{0, plural, one {<0>Masz <1>1 oczekujące zaproszenie} few {<2>M #: apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx #: apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx msgid "{0, plural, one {1 Field Remaining} other {# Fields Remaining}}" -msgstr "{0, plural, one {Pozostało 1 pole} few {Pozostały # pola} many {Pozostało # pól} other {Pozostało # pola}}" +msgstr "{0, plural, one {Pozostało 1 pole} few {Pozostały # pola} many {Pozostało # pól} other {Pozostało # pól}}" #. placeholder {0}: fieldsOnExcessPages.length #: apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx msgid "{0, plural, one {1 field will be deleted because the new PDF has fewer pages than the current one.} other {# fields will be deleted because the new PDF has fewer pages than the current one.}}" -msgstr "{0, plural, one {1 pole zostanie usunięte, ponieważ nowy plik PDF ma mniejszą liczbę stron niż obecny.} few {# pola zostaną usunięte, ponieważ nowy plik PDF ma mniejszą liczbę stron niż obecny.} many {# pól zostanie usuniętych, ponieważ nowy plik PDF ma mniejszą liczbę stron niż obecny.} other {# pola zostanie usuniętych, ponieważ nowy plik PDF ma mniejszą liczbę stron niż obecny.}}" +msgstr "{0, plural, one {1 pole zostanie usunięte, ponieważ nowy plik PDF ma mniejszą liczbę stron niż obecny.} few {# pola zostaną usunięte, ponieważ nowy plik PDF ma mniejszą liczbę stron niż obecny.} many {# pól zostanie usuniętych, ponieważ nowy plik PDF ma mniejszą liczbę stron niż obecny.} other {# pól zostanie usuniętych, ponieważ nowy plik PDF ma mniejszą liczbę stron niż obecny.}}" #. placeholder {0}: fields.filter((field) => field.envelopeItemId === doc.id).length #. placeholder {0}: remainingFields.filter((field) => field.envelopeItemId === doc.id).length @@ -264,7 +264,7 @@ msgstr "{0, plural, one {Znaleźliśmy # odbiorcę w dokumencie.} few {Znaleźli #. placeholder {0}: envelopeIds.length #: apps/remix/app/components/dialogs/envelopes-bulk-cancel-dialog.tsx msgid "{0, plural, one {You are about to cancel the selected document.} other {You are about to cancel # documents.}}" -msgstr "{0, plural, one {Zamierzasz anulować zaznaczony dokument.} few {Zamierzasz anulować # dokumenty.} many {Zamierzasz anulować # dokumentów.} other {Zamierzasz anulować # dokumentów.}}" +msgstr "{0, plural, one {Zamierzasz anulować dokument.} few {Zamierzasz anulować # dokumenty.} many {Zamierzasz anulować # dokumentów.} other {Zamierzasz anulować # dokumentów.}}" #. placeholder {0}: envelopeIds.length #: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx @@ -318,7 +318,7 @@ msgstr "Pozostało {0} z {1} dokumentów w tym miesiącu." #. placeholder {2}: envelope.title #: packages/lib/server-only/document/resend-document.ts msgid "{0} on behalf of \"{1}\" has invited you to {recipientActionVerb} the document \"{2}\"." -msgstr "Sprawdź i {recipientActionVerb} dokument „{2}” utworzony przez użytkownika {0} z zespołu „{1}”." +msgstr "Sprawdź i {recipientActionVerb} dokument „{2}” utworzony przez użytkownika {0} z zespołu „{1}”." #. placeholder {0}: organisation.name #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx @@ -379,7 +379,7 @@ msgstr "Użytkownik {inviterName} anulował dokument<0/>„{documentName}”" #. placeholder {0}: _(actionVerb).toLowerCase() #: packages/email/template-components/template-document-invite.tsx msgid "{inviterName} has invited you to {0}<0/>\"{documentName}\"" -msgstr "Sprawdź i {0} dokument „{documentName}}” utworzony przez użytkownika {inviterName}" +msgstr "Sprawdź i {0} dokument „{documentName}” utworzony przez użytkownika {inviterName}" #: packages/email/templates/document-invite.tsx msgid "{inviterName} has invited you to {action} {documentName}" @@ -401,12 +401,12 @@ msgstr "Użytkownik {inviterName} usunął Cię z dokumentu<0/>„{documentName} #. placeholder {1}: envelope.title #: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts msgid "{inviterName} on behalf of \"{0}\" has invited you to {recipientActionVerb} the document \"{1}\"." -msgstr "Sprawdź i {recipientActionVerb} dokument „{1}” utworzony przez użytkownika {inviterName} z zespołu „{0}”." +msgstr "Sprawdź i {recipientActionVerb} dokument „{1}” utworzony przez użytkownika {inviterName} z zespołu „{0}”." #. placeholder {0}: _(actionVerb).toLowerCase() #: packages/email/template-components/template-document-invite.tsx msgid "{inviterName} on behalf of \"{teamName}\" has invited you to {0}<0/>\"{documentName}\"" -msgstr "Sprawdź i {0} dokument<0/>„{documentName}” utworzony przez użytkownika {inviterName} z zespołu „{teamName}”" +msgstr "Sprawdź i {0} dokument<0/>„{documentName}” utworzony przez użytkownika {inviterName} z zespołu „{teamName}”" #: packages/email/templates/document-invite.tsx msgid "{inviterName} on behalf of \"{teamName}\" has invited you to {action} {documentName}" @@ -425,7 +425,7 @@ msgstr "{maximumEnvelopeItemCount, plural, one {Nie możesz przesłać więcej n #: apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx msgid "{organisationClaimCount, plural, one {# Organisation claim} other {# Organisation claims}}" -msgstr "{organisationClaimCount, plural, one {# roszczenie organizacji} few {# roszczenia organizacji} many {# roszczeń organizacji} other {# roszczenia organizacji}}" +msgstr "{organisationClaimCount, plural, one {# subskrypcję organizacji} few {# subskrypcje organizacji} many {# subskrypcji organizacji} other {# subskrypcji organizacji}}" #: apps/remix/app/components/general/direct-template/direct-template-page.tsx msgid "{recipientActionVerb} document" @@ -480,7 +480,7 @@ msgstr "Użytkownik {signerName} odrzucił dokument „{documentName}”." #: apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx msgid "{subscriptionClaimCount, plural, one {# Subscription claim} other {# Subscription claims}}" -msgstr "{subscriptionClaimCount, plural, one {# roszczenie subskrypcji} few {# roszczenia subskrypcji} many {# roszczeń subskrypcji} other {# roszczenia subskrypcji}}" +msgstr "{subscriptionClaimCount, plural, one {# subskrypcję} few {# subskrypcje} many {# subskrypcji} other {# subskrypcji}}" #. placeholder {0}: _(actionVerb).toLowerCase() #: packages/email/template-components/template-document-invite.tsx @@ -505,15 +505,15 @@ msgstr "Użytkownik {user} zatwierdził dokument" #: packages/lib/utils/document-audit-logs.ts msgid "{user} authenticated with the signing provider" -msgstr "{user} został uwierzytelniony u dostawcy podpisu" +msgstr "Użytkownik {user} uwierzytelnił się u dostawcy podpisu" #: packages/lib/utils/document-audit-logs.ts msgid "{user} authorised the remote signature" -msgstr "{user} autoryzował zdalny podpis" +msgstr "Użytkownik {user} autoryzował podpis zdalny" #: packages/lib/utils/document-audit-logs.ts msgid "{user} cancelled the document" -msgstr "{user} anulował dokument" +msgstr "Użytkownik {user} anulował dokument" #: packages/lib/utils/document-audit-logs.ts msgid "{user} CC'd the document" @@ -580,7 +580,7 @@ msgstr "Użytkownik {user} poprosił o kod weryfikacyjny dla dokumentu" #: packages/lib/utils/document-audit-logs.ts msgid "{user} requested a remote signature" -msgstr "{user} zażądał zdalnego podpisu" +msgstr "Użytkownik {user} poprosił o podpis zdalny" #. placeholder {0}: data.recipientEmail #: packages/lib/utils/document-audit-logs.ts @@ -655,7 +655,7 @@ msgstr "Użytkownik {user} wyświetlił dokument" #: packages/lib/utils/document-audit-logs.ts msgid "{user}'s remote signature was applied" -msgstr "Zastosowano zdalny podpis użytkownika {user}" +msgstr "Użytkownik {user} złożył podpis zdalny" #: packages/lib/utils/document-audit-logs.ts msgid "{user}'s signing provider authentication failed" @@ -687,7 +687,7 @@ msgstr "{visibleRows, plural, one {# wynik} few {# wyniki} many {# wyników} oth #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx msgid "<0>\"{0}\" is no longer available to sign" -msgstr "<0>„{0}” nie jest już dostępny do podpisu" +msgstr "Dokument <0>„{0}” nie jest już dostępny do podpisu" #: packages/email/templates/organisation-account-link-confirmation.tsx msgid "<0>{organisationName} has requested to create an account on your behalf." @@ -1272,7 +1272,7 @@ msgstr "Dodaj dokument" #: packages/ui/primitives/document-flow/add-settings.tsx #: packages/ui/primitives/template-flow/add-template-settings.tsx msgid "Add a URL to redirect the user to once the document is signed" -msgstr "Dodaj adres URL do przekierowania użytkownika po podpisaniu dokumentu" +msgstr "Dodaj adres URL, na który użytkownik zostanie przekierowany po podpisaniu dokumentu" #: apps/remix/app/components/general/document/document-edit-form.tsx #: apps/remix/app/components/general/template/template-edit-form.tsx @@ -1302,11 +1302,11 @@ msgstr "Dodaj identyfikator zewnętrzny szablonu. Może być używany do identyf #: apps/remix/app/components/dialogs/envelopes-bulk-cancel-dialog.tsx msgid "Add an optional reason for cancelling these documents" -msgstr "Dodaj opcjonalny powód anulowania tych dokumentów" +msgstr "Dodaj opcjonalny powód anulowania dokumentów" #: apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx msgid "Add an optional reason for cancelling this document" -msgstr "Dodaj opcjonalny powód anulowania tego dokumentu" +msgstr "Dodaj opcjonalny powód anulowania dokumentu" #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx msgid "Add and configure multiple documents" @@ -1344,7 +1344,7 @@ msgstr "Dodaj domenę" #: apps/remix/app/components/dialogs/email-transport-create-dialog.tsx msgid "Add Email Transport" -msgstr "Dodaj transport e-mailowy" +msgstr "Dodaj dostawcę poczty e-mail" #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx msgid "Add fields" @@ -1416,8 +1416,8 @@ msgid "Add Placeholders" msgstr "Dodaj domyślny tekst" #: apps/remix/app/components/general/rate-limit-array-input.tsx -msgid "Add rate limit" -msgstr "Dodaj limit liczby żądań" +msgid "Add rate limit window" +msgstr "" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Add recipients" @@ -1477,7 +1477,7 @@ msgstr "Dodaj ten adres URL do dozwolonych adresów przekierowania Twojego dosta #: apps/remix/app/components/dialogs/email-transport-create-dialog.tsx msgid "Add transport" -msgstr "Dodaj transport" +msgstr "Dodaj dostawcę poczty e-mail" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Additional brand information to display at the bottom of emails" @@ -1513,7 +1513,7 @@ msgstr "Tylko dla administratorów" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Advanced — Custom CSS" -msgstr "Zaawansowane — własny CSS" +msgstr "Zaawansowane – niestandardowy CSS" #: packages/ui/primitives/document-flow/add-settings.tsx #: packages/ui/primitives/template-flow/add-template-settings.tsx @@ -1559,7 +1559,7 @@ msgstr "Wszystko" #: apps/remix/app/routes/_authenticated+/admin+/organisation-stats._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisation-stats._index.tsx msgid "All claims" -msgstr "Wszystkie zgłoszenia" +msgstr "Wszystkie subskrypcje" #: apps/remix/app/components/general/app-command-menu.tsx msgid "All documents" @@ -1638,7 +1638,7 @@ msgstr "Odpowiadanie odbiorcom dokumentu bezpośrednio na ten adres e-mail" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "Allow Personal Organisations" -msgstr "Zezwalaj na osobiste organizacje" +msgstr "Zezwól na osobiste organizacje" #: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -1800,7 +1800,7 @@ msgstr "Wystąpił błąd podczas wyłączania użytkownika." #: apps/remix/app/utils/toast-error-messages.ts msgid "An error occurred while distributing the document." -msgstr "Wystąpił błąd podczas dystrybucji dokumentu." +msgstr "Wystąpił błąd podczas wysyłania dokumentu." #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "An error occurred while enabling direct link signing." @@ -1907,11 +1907,11 @@ msgstr "Wystąpił błąd. Spróbuj ponownie później." #: packages/lib/jobs/definitions/emails/send-organisation-limit-alert-email.handler.ts msgid "An organisation has exceeded their fair use limits" -msgstr "Organizacja przekroczyła swoje limity dozwolonego użytkowania" +msgstr "Organizacja przekroczyła limit dozwolonego użytkowania" #: packages/lib/jobs/definitions/emails/send-organisation-limit-alert-email.handler.ts msgid "An organisation is nearing their fair use limits" -msgstr "Organizacja zbliża się do swoich limitów dozwolonego użytkowania" +msgstr "Organizacja zbliża się do limit dozwolonego użytkowania" #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx msgid "An organisation wants to create an account for you. Please review the details below." @@ -2008,6 +2008,7 @@ msgstr "Dowolne źródło" msgid "Any Status" msgstr "Dowolny status" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "API" msgstr "API" @@ -2017,17 +2018,13 @@ msgstr "API" msgid "API key" msgstr "Klucz API" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "API rate limits" -msgstr "Limity liczby żądań API" - #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "API requests" msgstr "Żądania API" #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx msgid "API requests have been temporarily paused" -msgstr "Żądania API zostały tymczasowo wstrzymane." +msgstr "Żądania API zostały tymczasowo wstrzymane" #: apps/remix/app/components/general/settings-nav-desktop.tsx #: apps/remix/app/components/general/settings-nav-mobile.tsx @@ -2039,7 +2036,7 @@ msgstr "Tokeny API" #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx msgid "API usage is approaching fair use limits" -msgstr "Wykorzystanie API zbliża się do limitów dozwolonego użytkowania" +msgstr "Wykorzystanie API zbliża się do limitu dozwolonego użytkowania" #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "App Version" @@ -2047,7 +2044,7 @@ msgstr "Wersja aplikacji" #: apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx msgid "Applying your signature" -msgstr "Trwa stosowanie Twojego podpisu" +msgstr "Składanie podpisu" #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx msgid "Approaching fair use limit" @@ -2057,7 +2054,7 @@ msgstr "Zbliżasz się do limitu dozwolonego użytkowania" #: packages/email/templates/organisation-limit-alert.tsx #: packages/lib/jobs/definitions/emails/send-organisation-limit-alert-email.handler.ts msgid "Approaching Your Plan Limits" -msgstr "Zbliżasz się do limitów swojego planu" +msgstr "Zbliżasz się do limitu planu" #: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx #: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -2118,7 +2115,7 @@ msgstr "Czy na pewno chcesz usunąć następującą subskrypcję?" #: apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx msgid "Are you sure you want to delete the following transport?" -msgstr "Czy na pewno chcesz usunąć następujący transport?" +msgstr "Czy na pewno chcesz usunąć następującego dostawcę poczty e-mail?" #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx msgid "Are you sure you want to delete this folder?" @@ -2205,7 +2202,7 @@ msgstr "Przygotowujący" #: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx msgid "Assistants and Copy roles are currently not compatible with the multi-sign experience." -msgstr "Przygotowujący i pobierający kopię nie są kompatybilni z funkcją wielu podpisów." +msgstr "Przygotowujący i pobierający kopię nie są kompatybilni z funkcją wielu podpisów." #: apps/remix/app/components/general/document/document-page-view-recipients.tsx msgid "Assisted" @@ -2328,7 +2325,7 @@ msgstr "Zadania w tle" #: apps/remix/app/components/dialogs/claim-update-dialog.tsx msgid "Backport email transport" -msgstr "Backport transportu e-mailowego" +msgstr "Użyj dostawcy poczty e-mail" #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx @@ -2345,11 +2342,11 @@ msgstr "Baner został zaktualizowany" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Base background colour." -msgstr "Bazowy kolor tła." +msgstr "Kolor tła." #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Base text colour." -msgstr "Bazowy kolor tekstu." +msgstr "Kolor tekstu." #: packages/email/template-components/template-confirmation-email.tsx msgid "Before you get started, please confirm your email address by clicking the button below:" @@ -2367,7 +2364,7 @@ msgstr "Płatności" #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Bio" -msgstr "Bio" +msgstr "Biografia" #: packages/ui/primitives/signature-pad/signature-pad-color-picker.tsx msgid "Black" @@ -2375,7 +2372,7 @@ msgstr "Czarny" #: apps/remix/app/components/general/admin-email-blocklist-section.tsx msgid "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\")." -msgstr "Blokuj rejestracje z dodatkowych domen e‑mail ponad dołączoną listę jednorazowych adresów. Subdomeny są dopasowywane automatycznie (np. zablokowanie \"bad.com\" powoduje również zablokowanie \"foo.bad.com\")." +msgstr "Zablokuj rejestracje z konkretnych domen. Subdomeny są blokowane automatycznie (np. domena.pl spowoduje również zablokowanie subdomena.domena.pl). Domeny tymczasowych adresów e-mail znajdują się na tej liście." #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "Blocked" @@ -2441,6 +2438,7 @@ msgstr "Logo marki" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding Preferences" msgstr "Ustawienia brandingu" @@ -2501,7 +2499,7 @@ msgstr "Akceptując prośbę, przyznasz zespołowi {0} następujące uprawnienia #: packages/email/templates/confirm-team-email.tsx msgid "By accepting this request, you will be granting <0>{teamName} access to:" -msgstr "Akceptując prośbę, umożliwisz zespołowi <0>{teamName} na:" +msgstr "Akceptując prośbę, umożliwisz zespołowi <0>{teamName}:" #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx msgid "By deleting this document, the following will occur:" @@ -2680,6 +2678,10 @@ msgstr "Nie można usunąć podpisującego" msgid "Cannot upload items after the document has been sent" msgstr "Nie możesz przesłać elementów po wysłaniu dokumentu" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Capabilities enabled for this organisation." +msgstr "" + #: packages/lib/constants/recipient-roles.ts msgctxt "Recipient role name" msgid "Cc" @@ -2711,7 +2713,7 @@ msgstr "Wyśrodkuj" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Field Type" -msgstr "Zmień typ pola" +msgstr "Zmień rodzaj pola" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change language" @@ -2800,7 +2802,7 @@ msgstr "Wybierz..." #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "Claim" -msgstr "Zgłoszenie" +msgstr "Subskrypcja" #: apps/remix/app/components/general/claim-account.tsx msgid "Claim account" @@ -2830,7 +2832,7 @@ msgstr "Kliknij, aby rozpocząć" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx #: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx msgid "Click here to retry" -msgstr "Kliknij tutaj, aby spróbować ponownie" +msgstr "Kliknij, aby spróbować ponownie" #: apps/remix/app/components/dialogs/organisation-member-invite-dialog.tsx msgid "Click here to upload" @@ -2902,7 +2904,7 @@ msgstr "Zwiń panel boczny" #: apps/remix/app/components/general/admin-email-blocklist-section.tsx msgid "Comma-separated list of email domains to block from signing up." -msgstr "Lista domen e‑mail do zablokowania przy rejestracji, oddzielonych przecinkami." +msgstr "Lista zablokowanych domen do rejestracji oddzielona przecinkami." #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx msgid "Communication" @@ -3018,7 +3020,7 @@ msgstr "Skonfiguruj ustawienia zabezpieczeń dokumentu." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure signing reminder settings for the document." -msgstr "Skonfiguruj przypomnienia o podpisaniu dokumentu." +msgstr "Skonfiguruj ustawienia przypomnień o podpisaniu dokumentu." #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3050,7 +3052,7 @@ msgstr "Skonfiguruj role w zespole dla każdego użytkownika" #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure when and how often reminder emails are sent to recipients who have not yet completed signing. Uses the team default when set to inherit." -msgstr "Skonfiguruj, kiedy i jak często przypomnienia o podpisaniu będą wysyłane do odbiorców, którzy nie zakończyli dokumentu. Opcja dziedziczenia korzysta z domyślnych ustawień zespołu." +msgstr "Skonfiguruj, kiedy i jak często przypomnienia e-mail są wysyłane do odbiorców, którzy jeszcze nie ukończyli podpisywania. Gdy ustawione na dziedziczenie, używane są domyślne ustawienia zespołu." #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-checkbox-dialog.tsx @@ -3179,7 +3181,7 @@ msgstr "Wybierz język dokumentu, powiadomień i certyfikatu, który jest dołą #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Controls when and how often reminder emails are sent to recipients who have not yet completed signing." -msgstr "Skonfiguruj, kiedy i jak często przypomnienia o podpisaniu będą wysyłane do odbiorców, którzy nie zakończyli dokumentu." +msgstr "Określa, kiedy i jak często przypomnienia e-mail są wysyłane do odbiorców, którzy jeszcze nie ukończyli podpisywania." #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Controls whether the audit logs will be included in the document when it is downloaded. The audit logs can still be downloaded from the logs page separately." @@ -3272,7 +3274,7 @@ msgstr "Kopiuj wartość" #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Counter reset." -msgstr "Licznik zresetowany." +msgstr "Licznik został zresetowany." #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-create-dialog.tsx @@ -3304,7 +3306,7 @@ msgstr "Utwórz nową organizację z planem {planName}. Zachowaj obecną organiz #: 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 e‑mail powitalny z łączem do ustawienia hasła." +msgstr "Utwórz nowego użytkownika. Wiadomość powitalna zostanie wysłana z linkiem do ustawienia hasła." #: apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx msgid "Create a support ticket" @@ -3541,7 +3543,7 @@ msgstr "Tworzenie szablonu" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "CSS rules were dropped during sanitisation" -msgstr "Niektóre reguły CSS zostały odrzucone podczas oczyszczania" +msgstr "Reguły CSS zostały usunięte podczas oczyszczenia kodu" #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "CSV Structure" @@ -3565,13 +3567,14 @@ msgstr "Obecni odbiorcy:" #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Current usage against organisation limits." -msgstr "Obecne użycie względem limitów organizacji." +msgstr "Obecne wykorzystanie względem limitów organizacji." #: apps/remix/app/components/general/teams/team-inherit-member-alert.tsx msgid "Currently all organisation members can access this team" msgstr "Obecnie wszyscy użytkownicy organizacji mogą uzyskać dostęp tego zespołu" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "Branding możesz skonfigurować tylko w planie Teams i wyższym." @@ -3586,7 +3589,7 @@ msgstr "Niestandardowy plik {0} MB" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Custom CSS is sanitised on save. Layout-breaking properties, remote URLs, and pseudo-elements are stripped automatically. Any rules dropped during sanitisation will be shown after you save." -msgstr "Niestandardowy kod CSS jest czyszczony podczas zapisywania. Właściwości mogące zepsuć układ, zdalne adresy URL oraz pseudo‑elementy są automatycznie usuwane. Wszystkie reguły odrzucone w trakcie czyszczenia zostaną wyświetlone po zapisaniu." +msgstr "Niestandardowy CSS jest oczyszczany podczas zapisywania. Właściwości psujące układ graficzny, zdalne adresy URL oraz pseudoelementy są automatycznie usuwane. Reguły usunięte podczas oczyszczania kodu zostaną wyświetlone po zapisaniu zmian." #: packages/ui/components/document/expiration-period-picker.tsx msgid "Custom duration" @@ -3594,7 +3597,7 @@ msgstr "Niestandardowy czas" #: packages/ui/components/document/reminder-settings-picker.tsx msgid "Custom interval" -msgstr "Niestandardowy czas" +msgstr "Własny interwał" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups._index.tsx msgid "Custom Organisation Groups" @@ -3602,11 +3605,11 @@ msgstr "Niestandardowe grupy w organizacji" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Customise the colours used on your signing pages." -msgstr "Dostosuj kolory używane na stronach podpisywania." +msgstr "Dostosuj kolory na stronach podpisywania." #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Danger Zone" -msgstr "Strefa zagrożenia" +msgstr "Niebezpieczna strefa" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Dark Mode" @@ -3662,11 +3665,11 @@ msgstr "Odrzuć" #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Default (system mailer)" -msgstr "Domyślny (systemowy mailer)" +msgstr "Domyślny (systemowy)" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Default border colour." -msgstr "Domyślny kolor obramowania." +msgstr "Kolor obramowania." #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Date Format" @@ -3686,7 +3689,7 @@ msgstr "Domyślny adres e-mail" #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Default Email Settings" -msgstr "Domyślne ustawienia powiadomień" +msgstr "Domyślne ustawienia adresu e-mail" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Envelope Expiration" @@ -3850,7 +3853,7 @@ msgstr "Usuń domenę" #: apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx msgid "Delete Email Transport" -msgstr "Usuń transport e-mailowy" +msgstr "Usuń dostawcę poczty e-mail" #: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx msgid "Delete Envelope" @@ -3926,7 +3929,7 @@ msgstr "Usuwanie konta..." #: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx msgid "Deletion scheduled" -msgstr "Usunięcie zaplanowane" +msgstr "Usunięcie zostało zaplanowane" #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Destination" @@ -3997,7 +4000,7 @@ msgstr "Urządzenie" #: packages/email/template-components/template-footer.tsx msgid "Did not expect this email? <0>Click here to report the sender. Never sign a document you don't recognize or weren't expecting." -msgstr "Nie spodziewasz się tej wiadomości e-mail? <0>Kliknij tutaj, aby zgłosić nadawcę. Nigdy nie podpisuj dokumentu, którego nie rozpoznajesz lub którego się nie spodziewałeś(-aś)." +msgstr "Wiadomość nie była przez Ciebie oczekiwana? <0>Kliknij, aby zgłosić nadawcę. Nigdy nie podpisuj dokumentu, którego nie rozpoznajesz." #: packages/email/templates/reset-password.tsx msgid "Didn't request a password change? We are here to help you secure your account, just <0>contact us." @@ -4056,7 +4059,7 @@ msgstr "Bezpośredni link do szablonu został usunięty" #. placeholder {1}: quota.directTemplates #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Direct template link usage exceeded ({0}/{1})" -msgstr "Przekroczono limit użycia bezpośrednich linków do szablonu ({0} / {1})" +msgstr "Przekroczono limit wykorzystania bezpośrednich linków do szablonu ({0} / {1})" #: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx #: apps/remix/app/components/forms/editor/editor-field-radio-form.tsx @@ -4123,7 +4126,7 @@ msgstr "Wyświetlać Twoją nazwę i adres e-mail w dokumentach" #: apps/remix/app/components/forms/signup.tsx msgid "Disposable email addresses are not allowed. Please sign up with a permanent email address." -msgstr "Adresy e‑mail jednorazowego użytku nie są dozwolone. Zarejestruj się, używając stałego adresu e‑mail." +msgstr "Tymczasowe adresy e-mail nie są dozwolone. Zarejestruj się za pomocą standardowego adresu e-mail." #: apps/remix/app/components/general/document/document-edit-form.tsx msgid "Distribute Document" @@ -4205,17 +4208,17 @@ msgstr "Dokument został zatwierdzony" #: apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document cancelled" -msgstr "Dokument został anulowany" +msgstr "Anulowano dokument" #: packages/lib/utils/document-audit-logs.ts msgctxt "Audit log format" msgid "Document cancelled" -msgstr "Dokument został anulowany" +msgstr "Anulowano dokument" #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts -#: packages/lib/server-only/document/delete-document.ts msgid "Document Cancelled" msgstr "Dokument został anulowany" @@ -4242,7 +4245,7 @@ msgstr "Dokument został zakończony!" #: apps/remix/app/utils/toast-error-messages.ts msgid "Document conversion is temporarily unavailable. Please try again shortly or upload a PDF." -msgstr "Konwersja dokumentów jest tymczasowo niedostępna. Spróbuj ponownie za chwilę lub prześlij plik PDF." +msgstr "Konwertowanie dokumentu jest niedostępne. Spróbuj ponownie później lub prześlij plik PDF." #: apps/remix/app/components/dialogs/template-use-dialog.tsx msgid "Document created" @@ -4282,7 +4285,7 @@ msgstr "Tworzenie dokumentu" #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx msgid "Document creation has been temporarily paused" -msgstr "Tworzenie dokumentów zostało tymczasowo wstrzymane." +msgstr "Tworzenie dokumentów zostało tymczasowo wstrzymane" #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx @@ -4405,10 +4408,6 @@ msgstr "Ustawienia dokumentu" msgid "Document preferences updated" msgstr "Ustawienia dokumentu zostały zaktualizowane" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Document rate limits" -msgstr "Limity liczby dokumentów" - #: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document rejected" @@ -4426,7 +4425,7 @@ msgstr "Zmieniono nazwę dokumentu" #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx msgid "Document resent" -msgstr "Dokument został ponownie wysłany" +msgstr "Wysłano ponownie dokument" #: apps/remix/app/components/general/document/document-edit-form.tsx msgid "Document sent" @@ -4453,7 +4452,7 @@ msgstr "Zaktualizowano metodę uwierzytelniania odbiorcy do dokumentu" #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx msgid "Document signing process will be cancelled" -msgstr "Proces podpisywania dokumentu zostanie anulowany" +msgstr "Podpisywanie dokumentu zostanie anulowane" #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Document status" @@ -4505,7 +4504,7 @@ msgstr "Dokument został przesłany" #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx msgid "Document usage is approaching fair use limits" -msgstr "Wykorzystanie dokumentów zbliża się do limitów dozwolonego użytkowania" +msgstr "Wykorzystanie dokumentów zbliża się do limitu dozwolonego użytkowania" #: packages/lib/utils/document-audit-logs.ts msgctxt "Audit log format" @@ -4544,6 +4543,7 @@ msgstr "Dokumentacja" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-nav-desktop.tsx #: apps/remix/app/components/general/app-nav-mobile.tsx +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -4614,7 +4614,7 @@ msgstr "Dokumenty, które zostały podpisane przez wszystkich odbiorców, ale ni #: apps/remix/app/routes/_authenticated+/admin+/organisation-stats._index.tsx msgid "Documents, emails and api values may not be accurate since they record the amount of times the action was attempted. Meaning these values may go over the actual quota, get rejected, and will still be recorded." -msgstr "Dokumenty, e-maile i wartości API mogą nie być dokładne, ponieważ rejestrują liczbę prób wykonania danej akcji. Oznacza to, że te wartości mogą przekraczać faktyczny limit, być odrzucane, a mimo to nadal będą zapisywane." +msgstr "Wartości dokumentów, wiadomości i API mogą nie być dokładne, ponieważ rejestrują liczbę prób wykonania danej akcji. Oznacza to, że wartości te mogą przekroczyć rzeczywisty limit, zostać odrzucone, a mimo to nadal będą rejestrowane." #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx @@ -4708,7 +4708,7 @@ msgstr "Szkice dokumentów" #: packages/ui/primitives/document-dropzone.tsx msgid "Drag & drop your document here." -msgstr "Przeciągnij i upuść tutaj swój dokument." +msgstr "Przeciągnij i upuść dokument." #: apps/remix/app/components/embed/authoring/configure-document-upload.tsx msgid "Drag and drop or click to upload" @@ -4716,7 +4716,7 @@ msgstr "Przeciągnij i upuść lub kliknij, aby przesłać" #: apps/remix/app/components/general/envelope/envelope-drop-zone-wrapper.tsx msgid "Drag and drop your document here" -msgstr "Przeciągnij i upuść tutaj swój dokument" +msgstr "Przeciągnij i upuść dokument" #: packages/lib/constants/document.ts #: packages/ui/primitives/signature-pad/signature-pad.tsx @@ -4806,7 +4806,7 @@ msgstr "Np. 100" #: apps/remix/app/components/forms/email-transport-form.tsx msgid "e.g. Resend (free plans)" -msgstr "np. Resend (plany bezpłatne)" +msgstr "np. Resend (plan darmowy)" #: apps/remix/app/components/general/document/document-page-view-button.tsx #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx @@ -4824,7 +4824,7 @@ msgstr "Edytuj" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx msgid "Edit Email Transport" -msgstr "Edytuj transport e-mailowy" +msgstr "Edytuj dostawcę poczty e-mail" #: apps/remix/app/components/dialogs/envelope-item-edit-dialog.tsx msgid "Edit Item" @@ -4926,11 +4926,11 @@ msgstr "Adres e-mail już istnieje" #: apps/remix/app/components/general/admin-email-blocklist-section.tsx msgid "Email Blocklist" -msgstr "Bloklista e‑mail" +msgstr "Lista zablokowanych domen" #: apps/remix/app/components/general/admin-email-blocklist-section.tsx msgid "Email Blocklist Updated" -msgstr "Zaktualizowano bloklistę e‑mail" +msgstr "Lista została zaktualizowana" #: apps/remix/app/routes/_unauthenticated+/verify-email.$token.tsx msgid "Email Confirmed!" @@ -4942,11 +4942,11 @@ msgstr "Adres e-mail został utworzony" #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Email distribution needs to be enabled in the general settings tab to configure recipient email related settings." -msgstr "Aby móc skonfigurować ustawienia dotyczące wiadomości e-mail odbiorców, należy włączyć dystrybucję e-mail w zakładce ustawień ogólnych." +msgstr "Aby skonfigurować wiadomości, musisz włączyć dystrybucję dokumentu za pomocą poczty e-mail." #: apps/remix/app/components/general/admin-global-settings-section.tsx msgid "Email document settings" -msgstr "Ustawienia powiadomień" +msgstr "Ustawienia wysyłki dokumentu e‑mailem" #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Email Domain" @@ -4993,10 +4993,6 @@ msgstr "Ustawienia adresu e-mail" msgid "Email preferences updated" msgstr "Ustawienia adresu e-mail zostały zaktualizowane" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Email rate limits" -msgstr "Limity liczby wiadomości e-mail" - #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email recipients when a pending document is deleted" msgstr "Wyślij odbiorcom wiadomość, gdy oczekujący dokument zostanie usunięty" @@ -5031,7 +5027,7 @@ msgstr "Adres e-mail nadawcy" #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx msgid "Email sending has been temporarily paused" -msgstr "Wysyłanie wiadomości e-mail zostało tymczasowo wstrzymane." +msgstr "Wysyłanie wiadomości zostało tymczasowo wstrzymane" #: packages/lib/utils/document-audit-logs.ts msgctxt "Audit log format" @@ -5048,7 +5044,7 @@ msgstr "Ustawienia adresu e-mail" #: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx msgid "Email the organisation owner to notify them of the deletion." -msgstr "Wyślij e-mail do właściciela organizacji, aby powiadomić go o usunięciu." +msgstr "Wyślij właścicielowi organizacji powiadomienie o usunięciu." #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email the owner when a document is created from a direct template" @@ -5069,16 +5065,16 @@ msgstr "Wyślij podpisującemu wiadomość, jeśli dokument jest nadal oczekują #: apps/remix/app/components/forms/subscription-claim-form.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Email transport" -msgstr "Transport e-mailowy" +msgstr "Dostawca poczty e-mail" #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-transports._index.tsx msgid "Email Transports" -msgstr "Transporty e-mailowe" +msgstr "Dostawcy poczty e-mail" #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx msgid "Email usage is approaching fair use limits" -msgstr "Wykorzystanie e‑maili zbliża się do limitów dozwolonego użytkowania" +msgstr "Wykorzystanie wiadomości zbliża się do limitu dozwolonego użytkowania" #: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx msgid "Email verification" @@ -5092,6 +5088,7 @@ msgstr "Weryfikacja adresu e-mail została usunięta" msgid "Email verification has been resent" msgstr "Weryfikacja adresu e-mail została ponownie wysłana" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -5105,16 +5102,14 @@ msgstr "Adresy e-mail" msgid "Embedding, 5 members included and more" msgstr "Osadzanie dokumentów, 5 użytkowników i więcej" -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Empty = Unlimited, 0 = Blocked" -msgstr "Puste = Bez limitu, 0 = Zablokowane" - #: packages/ui/primitives/document-flow/add-fields.tsx msgid "Empty field" msgstr "Puste pole" +#: apps/remix/app/components/general/claim-limit-fields.tsx +msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." +msgstr "" + #: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx msgid "Enable" msgstr "Włącz" @@ -5216,7 +5211,7 @@ msgstr "Załączniki" #: apps/remix/app/components/forms/email-transport-form.tsx msgid "Endpoint (optional)" -msgstr "Endpoint (opcjonalnie)" +msgstr "Punkt końcowy (opcjonalnie)" #: packages/lib/constants/i18n.ts msgid "English" @@ -5234,6 +5229,10 @@ msgstr "Upewnij się, że używasz tokena osadzania, a nie tokenu API." msgid "Enter" msgstr "Wpisz" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a max request count greater than 0" +msgstr "" + #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." msgstr "Wpisz nazwę nowego folderu. Foldery pomagają uporządkować elementy." @@ -5242,6 +5241,10 @@ msgstr "Wpisz nazwę nowego folderu. Foldery pomagają uporządkować elementy." msgid "Enter a new title" msgstr "Wpisz nowy tytuł" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a window, e.g. 5m" +msgstr "" + #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Enter claim name" msgstr "Wpisz nazwę" @@ -5500,6 +5503,10 @@ msgstr "Wszyscy podpisali" msgid "Everyone has signed! You will receive an email copy of the signed document." msgstr "Wszyscy podpisali! Otrzymasz wiadomość z podpisanym dokumentem." +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Exceeded" +msgstr "" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Exceeded timeout" msgstr "Przekroczono limit czasu" @@ -5578,7 +5585,7 @@ msgstr "Nie udało się utworzyć zgłoszenia" #: apps/remix/app/components/dialogs/email-transport-create-dialog.tsx msgid "Failed to create transport." -msgstr "Nie udało się utworzyć transportu." +msgstr "Nie udało się utworzyć dostawcy poczty e-mail." #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx msgid "Failed to delete folder" @@ -5590,7 +5597,7 @@ msgstr "Nie udało się usunąć subskrypcji." #: apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx msgid "Failed to delete transport." -msgstr "Nie udało się usunąć transportu." +msgstr "Nie udało się usunąć dostawcy poczty e-mail." #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Failed to download audit logs. Please try again later." @@ -5634,7 +5641,7 @@ msgstr "Nie udało się zapisać ustawień." #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx msgid "Failed to save transport." -msgstr "Nie udało się zapisać transportu." +msgstr "Nie udało się zapisać dostawcy poczty e-mail." #: apps/remix/app/components/dialogs/session-logout-all-dialog.tsx msgid "Failed to sign out all sessions" @@ -5695,7 +5702,7 @@ msgstr "Niepowodzenie: {failedCount}" #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx #: apps/remix/app/utils/toast-error-messages.ts msgid "Fair use limit exceeded" -msgstr "Przekroczono limit dozwolonego użycia." +msgstr "Przekroczono limit dozwolonego użytkowania" #: apps/remix/app/components/forms/subscription-claim-form.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -5709,7 +5716,7 @@ msgstr "Funkcje" #: apps/remix/app/components/dialogs/admin-organisation-sync-subscription-dialog.tsx msgid "Fetch the latest subscription data from Stripe and apply it to this organisation." -msgstr "Pobierz najnowsze dane subskrypcji ze Stripe i zastosuj je do tej organizacji." +msgstr "Pobierz najnowsze dane subskrypcji z Stripe i zastosuj je w organizacji." #: packages/ui/primitives/document-flow/field-items-advanced-settings/text-field.tsx msgid "Field character limit" @@ -5785,7 +5792,7 @@ msgstr "Plik nie może być większy niż {APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB" #: apps/remix/app/components/dialogs/email-transport-create-dialog.tsx msgid "Fill in the details to create a new email transport." -msgstr "Uzupełnij szczegóły, aby utworzyć nowy transport e-mailowy." +msgstr "Uzupełnił szczegóły, aby utworzyć nowego dostawcę poczty e-mail." #: apps/remix/app/components/dialogs/claim-create-dialog.tsx msgid "Fill in the details to create a new subscription claim." @@ -5797,7 +5804,7 @@ msgstr "Filtruj według statusu" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Focus ring colour." -msgstr "Kolor obramowania aktywnego elementu (focus ring)." +msgstr "Kolor obramowania zaznaczenia." #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx msgid "Folder" @@ -5857,7 +5864,7 @@ msgstr "Na przykład, jeśli subskrypcja ma nową flagę „FLAG_1” ustawioną #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Foreground" -msgstr "Kolor pierwszego planu" +msgstr "Tekst" #: apps/remix/app/routes/_unauthenticated+/check-email.tsx msgid "Forgot password" @@ -5891,7 +5898,7 @@ msgstr "Darmowa" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgctxt "Subscription status" msgid "Free (Pending)" -msgstr "Darmowy (oczekujący)" +msgstr "Darmowa (oczekująca)" #: packages/lib/utils/fields.ts #: packages/ui/primitives/document-flow/types.ts @@ -6147,11 +6154,11 @@ msgstr "Cześć, jestem Timur" #: packages/email/template-components/template-document-reminder.tsx msgid "Hi {recipientName}," -msgstr "Cześć {recipientName}," +msgstr "Cześć, {recipientName}," #: packages/email/templates/bulk-send-complete.tsx msgid "Hi {userName}," -msgstr "Cześć {userName}," +msgstr "Cześć, {userName}" #: packages/email/template-components/template-access-auth-2fa.tsx msgid "Hi {userName}, you need to enter a verification code to complete the document \"{documentTitle}\"." @@ -6159,7 +6166,7 @@ msgstr "Cześć {userName}, wpisz kod weryfikacyjny, aby zakończyć dokument #: packages/email/templates/reset-password.tsx msgid "Hi, {userName} <0>({userEmail})" -msgstr "Cześć {userName} <0>({userEmail})" +msgstr "Cześć, {userName} <0>({userEmail})" #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx @@ -6201,7 +6208,7 @@ msgstr "Czas, w którym odbiorcy muszą zakończyć dokument. Opcja dziedziczeni #: 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" +msgstr "Weryfikacja antyspamowa jest wymagana" #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx msgid "I agree to link my account with this organization" @@ -6230,7 +6237,7 @@ msgstr "Muszę otrzymać kopię dokumentu" #: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx #: packages/lib/server-only/pdf/render-certificate.ts msgid "I am the owner of this document" -msgstr "Jestem właścicielem tego dokumentu" +msgstr "Jestem właścicielem dokumentu" #: apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx msgid "I understand that I am providing my credentials to a 3rd party service configured by this organisation" @@ -6270,11 +6277,11 @@ msgstr "Jeśli korzystasz ze środowiska „staging”, ustaw właściwość hos #: apps/remix/app/routes/_recipient+/report.$token.tsx msgid "If you did not expect this email or believe it is spam, you can report the sender to our team for review." -msgstr "Jeśli nie spodziewałeś(-aś) się tej wiadomości e-mail lub uważasz, że to spam, możesz zgłosić nadawcę do naszego zespołu do weryfikacji." +msgstr "Jeśli wiadomość nie była przez Ciebie oczekiwana lub uważasz, że jest to spam, zgłoś nadawcę do naszego zespołu." #: 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." -msgstr "Jeśli nie spodziewałeś(-aś) się tego konta lub masz jakiekolwiek pytania, <0>skontaktuj się z pomocą techniczną." +msgstr "Jeśli utworzenie konta jest błędem lub masz pytania, <0>skontaktuj się z pomocą techniczną." #: packages/email/template-components/template-access-auth-2fa.tsx msgid "If you didn't request this verification code, you can safely ignore this email." @@ -6290,7 +6297,7 @@ msgstr "Jeśli nie znajdziesz wiadomości z linkiem potwierdzającym, możesz po #: packages/email/templates/organisation-limit-alert.tsx msgid "If you expect to need higher limits, please contact support at {SUPPORT_EMAIL} and we will review your account." -msgstr "Jeśli spodziewasz się potrzeby wyższych limitów, skontaktuj się z działem wsparcia pod adresem {SUPPORT_EMAIL}, a my przejrzymy Twoje konto." +msgstr "Jeśli potrzebujesz wyższych limitów, skontaktuj się z pomocą techniczną pod adresem {SUPPORT_EMAIL}." #: apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx msgid "If your authenticator app does not support QR codes, you can use the following code instead:" @@ -6536,7 +6543,7 @@ msgstr "Adres IP" #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Irreversible actions for this organisation" -msgstr "Nieodwracalne działania dla tej organizacji" +msgstr "Nieodwracalne akcje dla organizacji" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "Issuer URL" @@ -6712,7 +6719,7 @@ msgstr "Pozostaw puste, aby odziedziczyć z organizacji." #: apps/remix/app/components/forms/email-transport-form.tsx msgid "Leave blank to keep current" -msgstr "Pozostaw puste, aby zachować obecne" +msgstr "Pozostaw puste, aby nie zmieniać" #: apps/remix/app/components/dialogs/organisation-leave-dialog.tsx msgid "Leave organisation" @@ -6763,6 +6770,10 @@ msgstr "Tryb jasny" msgid "Like to have your own public profile with agreements?" msgstr "Czy chcesz mieć własny profil publiczny z umowami?" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Limit reached" +msgstr "" + #: apps/remix/app/components/general/claim-limit-fields.tsx msgid "Limits" msgstr "Limity" @@ -6882,7 +6893,7 @@ msgstr "Zarządzaj niestandardowym logowaniem SSO dla swojej organizacji." #: apps/remix/app/routes/_authenticated+/admin+/email-transports._index.tsx msgid "Manage all email transports" -msgstr "Zarządzaj wszystkimi transportami e-mailowymi" +msgstr "Zarządzaj wszystkimi dostawcami poczty e-mail" #: apps/remix/app/routes/_authenticated+/settings+/organisations.tsx msgid "Manage all organisations you are currently associated with." @@ -7066,7 +7077,11 @@ msgstr "MAU (zalogowani)" #: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "Max" -msgstr "Max" +msgstr "Maks." + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Max requests" +msgstr "" #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." @@ -7075,7 +7090,7 @@ msgstr "Maksymalny rozmiar pliku to 4 MB. Możesz przesłać maksymalnie 100 wie #: apps/remix/app/components/forms/subscription-claim-form.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Maximum number of recipients per document allowed. 0 = Unlimited" -msgstr "Maksymalna dozwolona liczba odbiorców na dokument. 0 = Bez limitu" +msgstr "Maksymalna dozwolona liczba odbiorców dokumentu. 0 = bez ograniczeń" #: apps/remix/app/components/forms/subscription-claim-form.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -7114,12 +7129,12 @@ msgstr "Data dołączenia" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -7151,7 +7166,7 @@ msgstr "Środek" #: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "Min" -msgstr "Min" +msgstr "Min." #: apps/remix/app/components/general/admin-license-status-banner.tsx msgid "Missing License - Your Documenso instance is using features that require a license." @@ -7168,7 +7183,7 @@ msgstr "Edytuj odbiorców" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx msgid "Modify the details of the email transport." -msgstr "Zmień szczegóły transportu e-mailowego." +msgstr "Edytuj szczegóły dostawcy poczty e-mail." #: apps/remix/app/components/dialogs/claim-update-dialog.tsx msgid "Modify the details of the subscription claim." @@ -7188,16 +7203,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Miesięczna liczba aktywnych użytkowników: Użytkownicy, którzy zakończyli co najmniej jeden dokument" #: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly API quota" -msgstr "Miesięczny limit API" +msgid "Monthly quota" +msgstr "" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly document quota" -msgstr "Miesięczny limit dokumentów" - -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly email quota" -msgstr "Miesięczny limit e‑maili" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Monthly usage" +msgstr "" #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -7288,7 +7299,6 @@ msgid "Name" msgstr "Nazwa" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Name is required" msgstr "Nazwa jest wymagana" @@ -7296,9 +7306,13 @@ msgstr "Nazwa jest wymagana" msgid "Name Settings" msgstr "Ustawienia nazwy" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Near limit" +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Need to sign documents?" -msgstr "Potrzebujesz podpisać dokumenty?" +msgstr "Potrzebujesz podpisywać dokumenty?" #: packages/ui/components/recipient/recipient-role-select.tsx msgid "Needs to approve" @@ -7406,7 +7420,7 @@ msgstr "Brak włączonych funkcji" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "No field type matching this description was found." -msgstr "Nie znaleziono typu pola pasującego do tego opisu." +msgstr "Nie znaleziono pola pasującego do tego opisu." #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx msgid "No fields were detected in your document." @@ -7435,6 +7449,10 @@ msgstr "Nie musisz nic więcej robić." msgid "No groups found" msgstr "Nie znaleziono grup" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "No inherited claim" +msgstr "" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No License Configured" msgstr "Brak skonfigurowanej licencji" @@ -7517,7 +7535,7 @@ msgstr "Nie znaleziono pola podpisu" #: apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx msgid "No signing credentials available" -msgstr "Brak dostępnych poświadczeń do podpisu" +msgstr "Brak dostępnych certyfikatów podpisu" #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "No Stripe customer attached" @@ -7595,7 +7613,7 @@ msgstr "Nieobsługiwane" #: apps/remix/app/components/tables/documents-table-empty-state.tsx msgid "Nothing cancelled" -msgstr "Nic nie zostało anulowane" +msgstr "Nic nie anulowano" #: apps/remix/app/components/tables/documents-table-empty-state.tsx #: apps/remix/app/components/tables/documents-table-empty-state.tsx @@ -7628,12 +7646,12 @@ msgstr "Liczba musi być w formacie {numberFormat}" #: apps/remix/app/components/forms/subscription-claim-form.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Number of members allowed. 0 = Unlimited" -msgstr "Liczba dozwolonych użytkowników. 0 = Bez ograniczeń" +msgstr "Liczba dozwolonych użytkowników. 0 = bez ograniczeń" #: apps/remix/app/components/forms/subscription-claim-form.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Number of teams allowed. 0 = Unlimited" -msgstr "Liczba dozwolonych zespołów. 0 = Bez ograniczeń" +msgstr "Liczba dozwolonych zespołów. 0 = bez ograniczeń" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx msgid "Number Settings" @@ -7704,7 +7722,7 @@ msgstr "Dozwolone są tylko pliki PDF" #: apps/remix/app/components/dialogs/envelopes-bulk-cancel-dialog.tsx msgid "Only pending documents you have permission to manage will be cancelled." -msgstr "Tylko oczekujące dokumenty, do których masz uprawnienia do zarządzania, zostaną anulowane." +msgstr "Tylko oczekujące dokumenty, do których masz uprawnienia, zostaną anulowane." #: apps/remix/app/components/general/generic-error-layout.tsx #: apps/remix/app/components/general/generic-error-layout.tsx @@ -7768,7 +7786,7 @@ msgstr "Organizacja" #: packages/lib/server-only/organisation/delete-organisation-email.ts msgid "Organisation \"{organisationName}\" has been deleted" -msgstr "Organizacja \"{organisationName}\" została usunięta" +msgstr "Organizacja „{organisationName}” została usunięta" #: packages/lib/constants/organisations-translations.ts msgid "Organisation Admin" @@ -7844,7 +7862,7 @@ msgstr "Organizacja nie została znaleziona" #: packages/email/templates/organisation-limit-alert.tsx #: packages/lib/jobs/definitions/emails/send-organisation-limit-alert-email.handler.ts msgid "Organisation Review Required" -msgstr "Wymagana weryfikacja organizacji" +msgstr "Weryfikacja organizacji jest wymagana" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx @@ -7894,11 +7912,11 @@ msgstr "Adres URL organizacji" #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Organisation usage" -msgstr "Użycie organizacji" +msgstr "Wykorzystanie organizacji" #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx msgid "Organisation-level pending invites for this team's parent organisation." -msgstr "Oczekujące zaproszenia na poziomie organizacji zespołu." +msgstr "Oczekujące zaproszenia na poziomie organizacji dla organizacji nadrzędnej tego zespołu." #: apps/remix/app/components/general/org-menu-switcher.tsx #: apps/remix/app/components/general/settings-nav-desktop.tsx @@ -7915,7 +7933,7 @@ msgstr "Organizacje użytkownika." #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Organisations without a transport use the system default mailer." -msgstr "Organizacje bez zdefiniowanego transportu używają domyślnego systemowego mailera." +msgstr "Organizacje bez dostawcy poczty e-mail używają domyślnego systemu wysyłki." #: apps/remix/app/components/general/folder/folder-card.tsx msgid "Organise your documents" @@ -7942,6 +7960,11 @@ msgstr "Oryginalny" msgid "Otherwise, the document will be created as a draft." msgstr "W przeciwnym razie dokument zostanie utworzony jako wersja robocza." +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Overlapping fields detected" +msgstr "" + #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Override organisation settings" @@ -7966,15 +7989,15 @@ msgstr "Właściciel" #: apps/remix/app/components/general/admin-global-settings-section.tsx msgid "Owner document completed" -msgstr "Powiadomiono właściciela o zakończeniu dokumentu" +msgstr "Dokument właściciela zakończony" #: apps/remix/app/components/general/admin-global-settings-section.tsx msgid "Owner document created" -msgstr "Powiadomiono właściciela o utworzeniu dokumentu" +msgstr "Dokument właściciela utworzony" #: apps/remix/app/components/general/admin-global-settings-section.tsx msgid "Owner recipient expired" -msgstr "Powiadomiono właściciela o minięciu czasu na podpisanie dokumentu" +msgstr "Odbiorca właściciela wygasł" #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx msgid "Ownership transferred to {organisationMemberName}." @@ -7998,7 +8021,7 @@ msgstr "Opłacona" #: apps/remix/app/components/dialogs/envelope-download-dialog.tsx msgctxt "Partially signed document (adjective)" msgid "Partial" -msgstr "Częściowo podpisany" +msgstr "Częściowy" #: apps/remix/app/components/forms/signin.tsx #: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx @@ -8096,7 +8119,7 @@ msgstr "Zaległa płatność" #: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx #: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx msgid "Payment required" -msgstr "Wymagana płatność" +msgstr "Płatność jest wymagana" #: apps/remix/app/components/dialogs/envelope-download-dialog.tsx msgid "PDF Document" @@ -8132,7 +8155,7 @@ msgstr "Oczekujące dokumenty" #: apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx msgid "Pending documents will have their signing process cancelled" -msgstr "Proces podpisywania oczekujących dokumentów zostanie anulowany" +msgstr "Podpisywanie oczekujących dokumentów zostanie anulowane" #: apps/remix/app/components/general/organisations/organisation-invitations.tsx msgid "Pending invitations" @@ -8146,6 +8169,10 @@ msgstr "Oczekujące zaproszenia do organizacji" msgid "Pending since" msgstr "Oczekuje od" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "People with access to this organisation." +msgstr "" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -8163,7 +8190,7 @@ msgstr "Okres" #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible via the deleted-account service account." -msgstr "Trwale usuń tę organizację. Dokumenty staną się osierocone (nieusunięte), aby pozostały dostępne za pośrednictwem konta serwisowego usuniętego konta." +msgstr "Usuń trwale organizację. Dokumenty zostaną osierocone (nie usunięte), więc będą przypisane do konta serwisowego usuniętego konta." #: apps/remix/app/components/tables/user-organisations-table.tsx msgctxt "Personal organisation (adjective)" @@ -8215,7 +8242,7 @@ msgstr "Domyślny tekst" #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Plans without a transport use the system default mailer." -msgstr "Plany bez zdefiniowanego transportu używają domyślnego systemowego mailera." +msgstr "Plany bez dostawcy poczty e-mail używają domyślnego systemu wysyłki." #. placeholder {0}: _(actionVerb).toLowerCase() #: packages/email/template-components/template-document-invite.tsx @@ -8259,7 +8286,7 @@ msgstr "Wybierz nowe hasło" #: 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." +msgstr "Zakończ weryfikację CAPTCHA przed logowaniem." #: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx #: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx @@ -8286,7 +8313,7 @@ msgstr "Jeśli masz pytania, skontaktuj się z <0>pomocą techniczną." #: packages/email/templates/organisation-limit-alert.tsx msgid "Please contact support at {SUPPORT_EMAIL} and we will review your account." -msgstr "Skontaktuj się z pomocą techniczną pod adresem {SUPPORT_EMAIL}, a my zweryfikujemy Twoje konto." +msgstr "Skontaktuj się z pomocą techniczną {SUPPORT_EMAIL}. Sprawdzimy Twoje konto." #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx msgid "Please contact support if you would like to revert this action." @@ -8298,7 +8325,7 @@ msgstr "Skontaktuj się z właścicielem dokumentu, aby uzyskać pomoc." #: apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx msgid "Please don't close this tab. The signing provider is finalising your signature." -msgstr "Prosimy nie zamykać tej karty. Dostawca podpisu finalizuje Twój podpis." +msgstr "Nie zamykaj karty. Dostawca podpisu finalizuje podpis." #: apps/remix/app/components/forms/token.tsx msgid "Please enter a meaningful name for your token. This will help you identify it later." @@ -8308,10 +8335,6 @@ msgstr "Wpisz nazwę tokena. Pomoże to później w jego identyfikacji." msgid "Please enter a number" msgstr "Wpisz liczbę" -#: apps/remix/app/components/general/claim-account.tsx -msgid "Please enter a valid name." -msgstr "Wpisz prawidłową nazwę." - #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Please enter a valid number" msgstr "Wpisz prawidłową liczbę" @@ -8419,7 +8442,7 @@ msgstr "Spróbuj ponownie i upewnij się, że adres e-mail jest prawidłowy." #: apps/remix/app/components/general/pdf-viewer/pdf-viewer-states.tsx msgid "Please try again or contact our support." -msgstr "Spróbuj ponownie lub skontaktuj się z naszym wsparciem." +msgstr "Spróbuj ponownie lub skontaktuj się z pomocą techniczną" #. placeholder {0}: `'${t(deleteMessage)}'` #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx @@ -8507,15 +8530,15 @@ msgstr "Podgląd podpisanego dokumentu z domyślnymi opcjami" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Primary" -msgstr "Kolor główny" +msgstr "Główny" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Primary action colour." -msgstr "Kolor głównej akcji." +msgstr "Kolor akcji." #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Primary Foreground" -msgstr "Kolor pierwszego planu dla koloru głównego" +msgstr "Tekst przycisków" #: apps/remix/app/components/general/template/template-type.tsx #: apps/remix/app/components/tables/templates-table.tsx @@ -8696,7 +8719,7 @@ msgstr "Aby podpisać pole, wymagane jest ponowne uwierzytelnianie" #: apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx msgid "Reauthorise and retry" -msgstr "Ponownie autoryzuj i spróbuj ponownie" +msgstr "Spróbuj ponownie" #: packages/ui/components/recipient/recipient-role-select.tsx msgid "Receives copy" @@ -8741,11 +8764,11 @@ msgstr "Odbiorca zatwierdził dokument" #: packages/lib/utils/document-audit-logs.ts msgid "Recipient authenticated with the signing provider" -msgstr "Odbiorca został uwierzytelniony u dostawcy podpisu" +msgstr "Odbiorca uwierzytelnił się u dostawcy podpisu" #: packages/lib/utils/document-audit-logs.ts msgid "Recipient authorised the remote signature" -msgstr "Odbiorca autoryzował zdalny podpis" +msgstr "Odbiorca autoryzował podpis zdalny" #: packages/lib/utils/document-audit-logs.ts msgid "Recipient CC'd the document" @@ -8795,7 +8818,7 @@ msgstr "Odbiorca poprosił o kod weryfikacyjny dla dokumentu" #: packages/lib/utils/document-audit-logs.ts msgid "Recipient requested a remote signature" -msgstr "Odbiorca zażądał zdalnego podpisu" +msgstr "Odbiorca poprosił o podpis zdalny" #: apps/remix/app/components/general/admin-global-settings-section.tsx msgid "Recipient signed" @@ -8811,7 +8834,7 @@ msgstr "Odbiorca podpisał dokument" #: apps/remix/app/components/general/admin-global-settings-section.tsx msgid "Recipient signing request" -msgstr "Poproszono odbiorcę o podpisanie" +msgstr "Prośba o podpisanie wysłana do odbiorcy" #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Recipient signing request email" @@ -8836,11 +8859,11 @@ msgstr "Odbiorca wyświetlił dokument" #: packages/lib/utils/document-audit-logs.ts msgid "Recipient's remote signature was applied" -msgstr "Zastosowano zdalny podpis odbiorcy" +msgstr "Odbiorca złożył podpis zdalny" #: packages/lib/utils/document-audit-logs.ts msgid "Recipient's signing provider authentication failed" -msgstr "Uwierzytelnianie dostawcy usługi podpisu odbiorcy nie powiodło się" +msgstr "Uwierzytelnienie odbiorcy u dostawcy podpisu nie powiodło się" #: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx #: apps/remix/app/components/general/document/document-page-view-recipients.tsx @@ -8868,7 +8891,7 @@ msgstr "Odbiorcy będą mogli podpisać dokument po jego wysłaniu" #: apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-cancel-dialog.tsx msgid "Recipients will be notified that the document was cancelled" -msgstr "Odbiorcy zostaną powiadomieni, że dokument został anulowany" +msgstr "Odbiorcy zostaną powiadomieni o anulowaniu dokumentu" #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx msgid "Recipients will still retain their copy of the document" @@ -8917,6 +8940,11 @@ msgstr "Adres URL przekierowania" msgid "Redirecting" msgstr "Przekierowywanie" +#. placeholder {0}: oidcProviderLabel || 'OIDC' +#: apps/remix/app/routes/_unauthenticated+/signin.tsx +msgid "Redirecting to {0}..." +msgstr "" + #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx msgid "Registration Successful" @@ -8963,7 +8991,7 @@ msgstr "Pamiętasz hasło? <0>Zaloguj się" #: packages/email/templates/document-reminder.tsx msgid "Reminder to {action} {documentName}" -msgstr "Sprawdź i {action} dokument {documentName}" +msgstr "Przypomnienie, aby {action} dokument „{documentName}”" #. placeholder {0}: envelope.documentMeta.subject #: packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts @@ -9045,6 +9073,10 @@ msgstr "Usuń użytkownika organizacji" msgid "Remove Organisation Member" msgstr "Usuń użytkownika organizacji" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Remove rate limit" +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Remove recipient" msgstr "Usuń odbiorcę" @@ -9107,7 +9139,7 @@ msgstr "Zgłoś nadawcę" #: apps/remix/app/routes/_recipient+/report.$token.tsx msgid "Report this sender?" -msgstr "Zgłosić tego nadawcę?" +msgstr "Zgłosić nadawcę?" #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx @@ -9192,9 +9224,7 @@ msgstr "Wyślij ponownie kopertę" msgid "Resend verification" msgstr "Wyślij ponownie wiadomość weryfikacyjną" -#: apps/remix/app/components/forms/organisation-update-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx -#: apps/remix/app/components/forms/team-update-form.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Reset" msgstr "Resetuj" @@ -9237,6 +9267,10 @@ msgstr "Rozwiąż" msgid "Resolve payment" msgstr "Rozwiąż płatność" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Resource blocked" +msgstr "" + #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Response" msgstr "Odpowiedź" @@ -9329,7 +9363,7 @@ msgstr "Wyrównaj do lewej" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Ring" -msgstr "Obramowanie (ring)" +msgstr "Obramowanie zaznaczenia" #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -9379,6 +9413,7 @@ msgid "Save as Template" msgstr "Zapisz jako szablon" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx msgid "Save changes" msgstr "Zapisz zmiany" @@ -9420,7 +9455,7 @@ msgstr "Szukaj identyfikatora lub nazwy" #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx msgid "Search by document title, team:123 or user:123" -msgstr "Szukaj po tytule dokumentu, team:123 lub user:123" +msgstr "Szukaj tytułu dokumentu, zespołu lub użytkownika" #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Search by domain or organisation name" @@ -9436,7 +9471,7 @@ msgstr "Szukaj nazwy lub adresu e-mail" #: apps/remix/app/routes/_authenticated+/admin+/email-transports._index.tsx msgid "Search by name or from address" -msgstr "Szukaj po nazwie lub adresie nadawcy" +msgstr "Szukaj nazwy lub adresu e-mail" #: apps/remix/app/routes/_authenticated+/admin+/organisations._index.tsx msgid "Search by organisation ID, name, customer ID or owner email" @@ -9448,7 +9483,7 @@ msgstr "Szukaj nazwy organizacji" #: apps/remix/app/routes/_authenticated+/admin+/organisation-stats._index.tsx msgid "Search by organisation name, URL or ID" -msgstr "Szukaj po nazwie organizacji, adresie URL lub ID" +msgstr "Szukaj nazwy organizacji, adresu URL lub identyfikatora" #: apps/remix/app/components/general/document/document-search.tsx msgid "Search documents..." @@ -9722,7 +9757,7 @@ msgstr "Wyślij" #: apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx msgid "Send a test email using this transport to verify the configuration." -msgstr "Wyślij wiadomość testową przy użyciu tego transportu, aby zweryfikować konfigurację." +msgstr "Wyślij wiadomość testową przy pomocy dostawcy poczty e-mail." #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Send a test webhook with sample data to verify your integration is working correctly." @@ -9781,11 +9816,11 @@ msgstr "Status wysyłki" #: apps/remix/app/components/tables/admin-email-transports-table.tsx msgid "Send test" -msgstr "Wyślij test" +msgstr "Wyślij wiadomość testową" #: apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx msgid "Send Test Email" -msgstr "Wyślij testową wiadomość e-mail" +msgstr "Wyślij wiadomość testową" #: apps/remix/app/components/tables/documents-table.tsx #: apps/remix/app/components/tables/inbox-table.tsx @@ -9812,6 +9847,10 @@ msgstr "Wysyłanie..." msgid "Sent" msgstr "Wysłano" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Sent this period" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" msgstr "Sesja została unieważniona" @@ -9838,7 +9877,7 @@ 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" +msgstr "Ustaw hasło Documenso" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-command-menu.tsx @@ -9891,7 +9930,7 @@ msgstr "Pokaż ustawienia zaawansowane" #: apps/remix/app/routes/_authenticated+/admin+/organisation-stats._index.tsx msgid "Show daily averages for documents, emails and API usages" -msgstr "Pokaż dzienne średnie dla dokumentów, e-maili i użycia API" +msgstr "Pokaż średnie dzienne wykorzystanie dokumentów, wiadomości i API" #: apps/remix/app/components/general/admin-license-card.tsx msgid "Show license key" @@ -9907,7 +9946,7 @@ msgstr "Pokaż użycie" #: apps/remix/app/routes/_authenticated+/admin+/organisation-stats._index.tsx msgid "Show usage with quotas" -msgstr "Pokaż użycie z limitami" +msgstr "Pokaż limity użycia" #: apps/remix/app/components/dialogs/sign-field-signature-dialog.tsx #: apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx @@ -9959,7 +9998,7 @@ msgstr "Podpisz dokument" #: apps/remix/app/routes/_recipient+/_layout.tsx msgid "Sign Document - Documenso" -msgstr "Podpisz dokument – Documenso" +msgstr "Podpisz dokument - Documenso" #: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx msgid "Sign Documents" @@ -10115,7 +10154,7 @@ msgstr "Podpisuje" #: apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx msgid "Signing algorithm is not supported" -msgstr "Algorytm podpisu nie jest obsługiwany" +msgstr "Algorytm podpisywania nie jest obsługiwany" #: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx #: packages/lib/server-only/pdf/render-certificate.ts @@ -10142,7 +10181,7 @@ msgstr "Czas na podpisanie minął" #: apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx msgid "Signing failed" -msgstr "Podpisanie nie powiodło się" +msgstr "Podpisywanie nie powiodło się" #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx msgid "Signing for" @@ -10219,6 +10258,11 @@ msgstr "Ustawienia strony" msgid "Skip" msgstr "Pomiń" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." +msgstr "" + #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "Niektórym podpisującym nie przypisano pola podpisu. Przypisz co najmniej jedno pole podpisu do każdego podpisującego." @@ -10273,7 +10317,7 @@ msgstr "Coś poszło nie tak" #: apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx msgid "Something went wrong while applying your signature. Please retry." -msgstr "Coś poszło nie tak podczas stosowania Twojego podpisu. Spróbuj ponownie." +msgstr "Coś poszło nie tak podczas składania podpisu. Spróbuj ponownie." #. placeholder {0}: data.teamName #: apps/remix/app/routes/_unauthenticated+/team.verify.email.$token.tsx @@ -10298,11 +10342,11 @@ msgstr "Coś poszło nie tak podczas ładowania kluczy dostępu." #: apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx msgid "Something went wrong while preparing the remote signature. Please try again." -msgstr "Coś poszło nie tak podczas przygotowywania zdalnego podpisu. Spróbuj ponownie." +msgstr "Coś poszło nie tak podczas przygotowywania podpisu zdalnego. Spróbuj ponownie." #: packages/lib/constants/pdf-viewer-i18n.ts msgid "Something went wrong while rendering the document, please try again or contact our support." -msgstr "Coś poszło nie tak podczas renderowania dokumentu. Spróbuj ponownie lub skontaktuj się z naszym wsparciem." +msgstr "Coś poszło nie tak podczas renderowania dokumentu. Spróbuj ponownie lub skontaktuj się z pomocą techniczną." #: packages/lib/constants/pdf-viewer-i18n.ts #: packages/lib/constants/pdf-viewer-i18n.ts @@ -10589,7 +10633,7 @@ msgstr "Synchronizuj" #: apps/remix/app/components/dialogs/admin-organisation-sync-subscription-dialog.tsx msgid "Sync claims. This will overwrite the current claim with the one resolved from the Stripe subscription." -msgstr "Zsynchronizuj roszczenia. Spowoduje to nadpisanie bieżącego roszczenia tym, które zostanie pobrane z subskrypcji Stripe." +msgstr "Synchronizuj subskrypcję. Spowoduje to nadpisanie ustawień Stripe." #: apps/remix/app/components/tables/organisation-email-domains-table.tsx msgid "Sync Email Domains" @@ -10607,7 +10651,7 @@ msgstr "Synchronizuj licencję z serwera" #: apps/remix/app/components/dialogs/admin-organisation-sync-subscription-dialog.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Sync Stripe subscription" -msgstr "Zsynchronizuj subskrypcję Stripe" +msgstr "Synchronizuj subskrypcję Stripe" #: packages/lib/utils/document-audit-logs.ts #: packages/lib/utils/document-audit-logs.ts @@ -10789,10 +10833,10 @@ msgid "Team URL" msgstr "Adres URL zespołu" #: apps/remix/app/components/general/org-menu-switcher.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx @@ -10803,6 +10847,10 @@ msgstr "Zespoły" msgid "Teams help you organise your work and collaborate with others. Create your first team to get started." msgstr "Zespoły pomagają organizować pracę i współpracować z innymi. Utwórz swój pierwszy zespół, aby rozpocząć." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Teams that belong to this organisation." +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Teams that this organisation group is currently assigned to" msgstr "Zespoły, do których przypisana jest grupa organizacji" @@ -10873,7 +10921,7 @@ msgstr "Zmieniono nazwę szablonu" #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx msgid "Template resent" -msgstr "Szablon został ponownie wysłany" +msgstr "Wysłano ponownie szablon" #: apps/remix/app/components/general/template/template-edit-form.tsx msgid "Template saved" @@ -10934,7 +10982,7 @@ msgstr "Wiadomość testowa została wysłana." #: apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx msgid "Test failed." -msgstr "Test nie powiódł się." +msgstr "Wiadomość testowa nie powiodła się." #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx msgid "Test Webhook" @@ -10950,7 +10998,7 @@ msgstr "Testowy webhook został wysłany" #: apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx msgid "test@example.com" -msgstr "test@example.com" +msgstr "test@przyklad.com" #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx @@ -10979,7 +11027,7 @@ msgstr "Kolor tekstu" #: apps/remix/app/components/forms/branding-preferences-form.tsx msgid "Text colour on primary buttons." -msgstr "Kolor tekstu na głównych przyciskach." +msgstr "Kolor tekstu na przyciskach." #: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx msgid "Text is required" @@ -10995,7 +11043,7 @@ msgstr "Podpisywanie zostało zakończone." #: apps/remix/app/routes/_recipient+/report.$token.tsx msgid "Thank you for letting us know, we have flagged this sender for review. If you have any concerns please feel free to reach out to our <0>support team." -msgstr "Dziękujemy za zgłoszenie — oznaczyliśmy tego nadawcę do sprawdzenia. Jeśli masz jakiekolwiek wątpliwości, skontaktuj się z naszym <0>zespołem wsparcia." +msgstr "Dziękujemy za zgłoszenie nadawcy. Zweryfikujemy go. Jeśli masz pytania, skontaktuj się z naszą <0>pomocą techniczną." #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Thank you for using Documenso to perform your electronic document signing. The purpose of this disclosure is to inform you about the process, legality, and your rights regarding the use of electronic signatures on our platform. By opting to use an electronic signature, you are agreeing to the terms and conditions outlined below." @@ -11039,7 +11087,7 @@ msgstr "Domyślny adres e-mail do wysyłania wiadomości do odbiorców" #: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx msgid "The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this deletion." -msgstr "Usunięcie zostanie wykonane w tle i może zająć do kilku minut. Nie uruchamiaj ponownie tego procesu usuwania." +msgstr "Usunięcie zostanie uruchomione w tle. Może to potrwać do kilku minut. Nie uruchamiaj ponownie operacji." #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx #: apps/remix/app/components/general/template/template-direct-link-badge.tsx @@ -11054,7 +11102,7 @@ msgstr "Nazwa wyświetlana dla adresu e-mail" #: apps/remix/app/utils/toast-error-messages.ts msgid "The document could not be created because of missing or invalid information. Please review the template's recipients and fields." -msgstr "Nie udało się utworzyć dokumentu z powodu brakujących lub nieprawidłowych danych. Sprawdź listę odbiorców i pola w szablonie." +msgstr "Nie można było utworzyć dokumentu z powodu brakujących lub nieprawidłowych informacji. Sprawdź odbiorców i pola szablonu." #: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx msgid "The Document has been deleted successfully." @@ -11091,7 +11139,7 @@ msgstr "Zmieniono właściciela dokumentu na {0} z zespołu {1}" #: apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-cancel-dialog.tsx msgid "The document signing process will be stopped" -msgstr "Proces podpisywania dokumentu zostanie zatrzymany" +msgstr "Podpisywanie dokumentu zostanie zatrzymane" #: apps/remix/app/utils/toast-error-messages.ts msgid "The document was created but could not be sent to recipients." @@ -11124,7 +11172,7 @@ msgstr "Dokument zostanie natychmiast wysłany do odbiorców." #: apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx msgid "The document will remain in your dashboard marked as Cancelled" -msgstr "Dokument pozostanie na Twoim pulpicie oznaczony jako „Anulowany”." +msgstr "Dokument pozostanie na pulpicie oznaczony jako anulowany" #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._layout.tsx msgid "The document you are looking for could not be found." @@ -11141,7 +11189,7 @@ msgstr "Nazwa dokumentu" #: apps/remix/app/components/dialogs/envelopes-bulk-cancel-dialog.tsx msgid "The documents will remain in your dashboard marked as Cancelled" -msgstr "Dokumenty pozostaną na Twoim pulpicie oznaczone jako „Anulowane”." +msgstr "Dokumenty pozostaną na pulpicie oznaczone jako anulowane" #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "The email address which will show up in the \"Reply To\" field in emails" @@ -11149,7 +11197,7 @@ msgstr "Adres e-mail, który pojawi się w polu „Odpowiedz do” w wiadomości #: apps/remix/app/components/general/admin-email-blocklist-section.tsx msgid "The email blocklist has been updated successfully." -msgstr "Bloklista e‑mail została pomyślnie zaktualizowana." +msgstr "Lista zablokowanych domen została zaktualizowana." #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx msgid "The email domain you are looking for may have been removed, renamed or may have never existed." @@ -11190,11 +11238,11 @@ msgstr "Wystąpiły następujące błędy:" #: packages/email/templates/organisation-delete.tsx msgid "The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data." -msgstr "Poniższa organizacja została usunięta przez administratora. Ty i członkowie Twojej organizacji nie będziecie już mieli dostępu do tej organizacji, jej zespołów ani powiązanych z nią danych." +msgstr "Organizacja została usunięta przez administratora. Użytkownicy organizacji nie będą mieli już do niej dostępu, w tym do jej zespołów i powiązanych danych." #: packages/email/templates/organisation-delete.tsx msgid "The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data." -msgstr "Poniższa organizacja została usunięta. Ty i członkowie Twojej organizacji nie będziecie już mieli dostępu do tej organizacji, jej zespołów ani powiązanych z nią danych." +msgstr "Organizacja została usunięta. Użytkownicy organizacji nie będą mieli już do niej dostępu, w tym do jej zespołów i powiązanych danych." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "The following recipients require an email address:" @@ -11240,7 +11288,7 @@ msgstr "Subskrypcja organizacji została zsynchronizowana ze Stripe." #: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx msgid "The organisation will be deleted in the background. Documents will be orphaned, not deleted." -msgstr "Organizacja zostanie usunięta w tle. Dokumenty staną się osierocone, nieusunięte." +msgstr "Organizacja zostanie usunięta w tle. Dokumenty zostaną osierocone, ale nie usunięte." #: apps/remix/app/routes/_authenticated+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -11337,7 +11385,7 @@ msgstr "Link do podpisywania został skopiowany do schowka." #: apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx msgid "The signing provider did not respond in time. Please retry." -msgstr "Dostawca usługi podpisu nie odpowiedział na czas. Spróbuj ponownie." +msgstr "Dostawca podpisu nie odpowiedział na czas. Spróbuj ponownie." #: packages/email/templates/recipient-expired.tsx msgid "The signing window for \"{recipientName}\" on document \"{documentName}\" has expired." @@ -11364,7 +11412,7 @@ msgstr "Zespół, którego szukasz, mógł zostać usunięty, zmieniony lub móg #: apps/remix/app/utils/toast-error-messages.ts msgid "The template or one of its recipients could not be found." -msgstr "Szablon lub jeden z jego odbiorców nie został znaleziony." +msgstr "Nie można odnaleźć szablonu lub jednego z jego odbiorców." #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "The template will be removed from your profile" @@ -11453,7 +11501,7 @@ msgstr "Brak aktywnych szkiców. Prześlij, aby utworzyć." #: apps/remix/app/components/tables/documents-table-empty-state.tsx msgid "There are no cancelled documents. Documents you cancel will remain here as a record that they were distributed." -msgstr "Brak anulowanych dokumentów. Dokumenty, które anulujesz, pozostaną tutaj jako zapis ich wysłania." +msgstr "Brak anulowanych dokumentów. Anulowane dokumenty pozostaną tutaj jako dowód ich wcześniejszej dystrybucji." #: apps/remix/app/components/tables/documents-table-empty-state.tsx msgid "There are no completed documents yet. Documents that you have created or received will appear here once completed." @@ -11522,7 +11570,7 @@ msgstr "Nie można zmienić dokumentu" #: apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx msgid "This document could not be cancelled at this time. Please try again." -msgstr "Tego dokumentu nie można teraz anulować. Spróbuj ponownie." +msgstr "Nie można anulować dokumentu. Spróbuj ponownie." #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx msgid "This document could not be deleted at this time. Please try again." @@ -11547,7 +11595,7 @@ msgstr "Dokument został już wysłany do odbiorcy, więc nie możesz go już ed #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx msgid "This document has been cancelled" -msgstr "Ten dokument został anulowany" +msgstr "Dokument został anulowany" #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "This document has been cancelled by the owner and is no longer available for others to sign." @@ -11568,7 +11616,7 @@ msgstr "Dokument został podpisany przez wszystkich odbiorców" #: apps/remix/app/utils/toast-error-messages.ts msgid "This document has too many recipients. Please remove some recipients or contact support if you need more." -msgstr "Ten dokument ma zbyt wielu adresatów. Usuń część adresatów lub skontaktuj się z pomocą techniczną, jeśli potrzebujesz większej liczby." +msgstr "Dokument ma zbyt wielu odbiorców. Usuń niektórych odbiorców lub skontaktuj się z pomocą techniczną, jeśli potrzebujesz więcej." #: apps/remix/app/components/general/document/document-certificate-qr-view.tsx msgid "This document is available in your Documenso account. You can view more details, recipients, and audit logs there." @@ -11629,11 +11677,11 @@ msgstr "Zostanie wysłana do odbiorcy, który właśnie podpisał dokument, gdy #: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx #: packages/ui/primitives/document-flow/add-signers.tsx msgid "This envelope cannot have more than {recipientCountLimit} recipients. Please contact support if you need more." -msgstr "Ta koperta nie może mieć więcej niż {recipientCountLimit} adresatów. Skontaktuj się z pomocą techniczną, jeśli potrzebujesz większej liczby." +msgstr "Koperta nie może więcej niż {recipientCountLimit} odbiorców. Skontaktuj się z pomocą techniczną, jeśli potrzebujesz więcej." #: apps/remix/app/components/embed/embed-paywall.tsx msgid "This feature is not available on your current plan" -msgstr "Funkcja nie jest dostępna w Twoim planie" +msgstr "Ta funkcja nie jest dostępna w Twoim obecnym planie." #: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them." @@ -11641,7 +11689,7 @@ msgstr "Pole nie może być modyfikowane ani usuwane. Gdy udostępnisz bezpośre #: apps/remix/app/utils/toast-error-messages.ts msgid "This file type isn't supported. Please upload a PDF or Word document." -msgstr "Ten typ pliku nie jest obsługiwany. Prześlij plik PDF lub dokument programu Word." +msgstr "Typ pliku nie jest obsługiwany. Prześlij dokument PDF lub Word." #: apps/remix/app/components/dialogs/folder-delete-dialog.tsx msgid "This folder contains multiple items. Deleting it will remove all subfolders and move all nested documents and templates to the root folder." @@ -11674,11 +11722,11 @@ msgstr "Link jest nieprawidłowy lub wygasł. Skontaktuj się ze swoim zespołem #: apps/remix/app/components/dialogs/team-member-delete-dialog.tsx msgid "This member is inherited from a group and cannot be removed from the team directly." -msgstr "Ten członek jest dziedziczony z grupy i nie można go bezpośrednio usunąć z zespołu." +msgstr "Użytkownik jest odziedziczony z grupy i nie może zostać usunięty bezpośrednio z zespołu." #: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx msgid "This organisation is awaiting payment. Complete checkout to unlock it." -msgstr "Ta organizacja oczekuje na płatność. Dokończ proces zakupu, aby ją odblokować." +msgstr "Organizacja oczekuje na płatność. Zakończ płatność, aby ją odblokować." #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx msgid "This organisation will have administrative control over your account. You can revoke this access later, but they will retain access to any data they've already collected." @@ -11758,7 +11806,7 @@ msgstr "Zostanie wysłana do właściciela po zakończeniu dokumentu." #: packages/ui/components/document/document-email-checkboxes.tsx msgid "This will be sent to the document owner when a recipient's signing window has expired." -msgstr "Zostanie wysłana do właściciela dokumentu, gdy minie czas na podpisanie przez odbiorcę\n" +msgstr "Zostanie wysłana do właściciela dokumentu, gdy minie czas na podpisanie przez odbiorcę" #: apps/remix/app/components/tables/organisation-email-domains-table.tsx msgid "This will check and sync the status of all email domains for this organisation" @@ -11902,7 +11950,7 @@ msgstr "Aby uzyskać dostęp do konta, potwierdź adres e-mail, klikając na lin #: 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:" +msgstr "Ustaw hasło, klikając przycisk poniżej:" #. placeholder {0}: recipient.email #: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx @@ -12011,7 +12059,7 @@ msgstr "Token nie został znaleziony" #: apps/remix/app/utils/toast-error-messages.ts msgid "Too many recipients" -msgstr "Zbyt wielu adresatów" +msgstr "Zbyt wielu odbiorców" #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx @@ -12024,7 +12072,7 @@ msgstr "Góra" #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "Total" -msgstr "Razem" +msgstr "Łącznie" #: apps/remix/app/routes/_authenticated+/admin+/stats.tsx msgid "Total Documents" @@ -12052,23 +12100,23 @@ msgstr "Przenieś dokumenty do innego zespołu" #: apps/remix/app/components/dialogs/email-transport-create-dialog.tsx msgid "Transport created." -msgstr "Transport został utworzony." +msgstr "Dostawca poczty e-mail został utworzony." #: apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx msgid "Transport deleted." -msgstr "Transport został usunięty." +msgstr "Dostawca poczty e-mail został usunięty." #: apps/remix/app/components/forms/email-transport-form.tsx msgid "Transport type" -msgstr "Typ transportu" +msgstr "Rodzaj dostawcy poczty e-mail" #: apps/remix/app/components/forms/email-transport-form.tsx msgid "Transport type cannot be changed after creation." -msgstr "Typu transportu nie można zmienić po utworzeniu." +msgstr "Nie można zmienić rodzaju dostawcy poczty e-mail po jego utworzeniu." #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx msgid "Transport updated." -msgstr "Transport został zaktualizowany." +msgstr "Dostawca poczty e-mail został zaktualizowany." #: apps/remix/app/components/dialogs/webhook-create-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx @@ -12249,6 +12297,7 @@ msgstr "Nieautoryzowany" msgid "Uncompleted" msgstr "Niezakończono" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "Cofnij" @@ -12280,10 +12329,8 @@ msgstr "Nieznana nazwa" #: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Unlimited" -msgstr "Nieograniczone" +msgstr "Bez ograniczeń" #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx msgid "Unlimited documents, API and more" @@ -12298,6 +12345,10 @@ msgstr "Rozłącz" msgid "Unpin" msgstr "Odepnij" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "Unsaved changes" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" @@ -12317,9 +12368,6 @@ msgstr "Grupa bez nazwy" #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx -#: apps/remix/app/components/forms/branding-preferences-form.tsx -#: apps/remix/app/components/forms/document-preferences-form.tsx -#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -12342,12 +12390,13 @@ msgstr "Zaktualizuj baner" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Update Billing" msgstr "Zaktualizuj płatności" #: apps/remix/app/components/general/admin-email-blocklist-section.tsx msgid "Update Blocklist" -msgstr "Zaktualizuj listę blokowanych adresów e-mail" +msgstr "Zaktualizuj listę zablokowanych domen" #: apps/remix/app/components/dialogs/claim-update-dialog.tsx msgid "Update Claim" @@ -12369,10 +12418,6 @@ msgstr "Zaktualizuj adres e-mail" msgid "Update Fields" msgstr "Zaktualizuj pola" -#: apps/remix/app/components/forms/organisation-update-form.tsx -msgid "Update organisation" -msgstr "Zaktualizuj organizację" - #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -12408,10 +12453,6 @@ msgstr "Zaktualizuj rolę" msgid "Update Subscription Claim" msgstr "Zaktualizuj subskrypcję" -#: apps/remix/app/components/forms/team-update-form.tsx -msgid "Update team" -msgstr "Zaktualizuj zespół" - #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx msgid "Update team email" @@ -12580,15 +12621,18 @@ msgstr "Przesyłanie" msgid "URL" msgstr "Adres URL" -#. placeholder {0}: selectedStat?.period || 'N/A' -#: apps/remix/app/components/general/organisation-usage-panel.tsx -msgid "Usage for period: {0}" -msgstr "Wykorzystanie za okres: {0}" - #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx msgid "Use" msgstr "Użyj" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h" +msgstr "" + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a unique window for each rate limit" +msgstr "" + #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx msgid "Use Authenticator" @@ -12614,7 +12658,7 @@ msgstr "Użyj klucza dostępu do uwierzytelniania" #: apps/remix/app/components/tables/admin-email-transports-table.tsx msgid "Used by claims" -msgstr "Używany przez roszczenia" +msgstr "Liczba powiązań" #: apps/remix/app/components/tables/admin-document-logs-table.tsx #: apps/remix/app/components/tables/document-logs-table.tsx @@ -12630,7 +12674,7 @@ 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ą" +msgstr "Użytkownik został utworzony. Wiadomość powitalna została wysłana." #: apps/remix/app/components/forms/password.tsx msgid "User has no password." @@ -12897,7 +12941,7 @@ msgstr "Wyświetl rekordy DNS dla tej domeny" #: apps/remix/app/routes/_authenticated+/admin+/organisation-stats._index.tsx msgid "View, sort and filter monthly usage stats across organisations" -msgstr "Przeglądaj, sortuj i filtruj statystyki miesięcznego wykorzystania w organizacjach" +msgstr "Sprawdź miesięczne statystyki wykorzystania organizacji" #: apps/remix/app/components/embed/multisign/multi-sign-document-list.tsx #: apps/remix/app/components/general/document/document-page-view-recipients.tsx @@ -12942,9 +12986,9 @@ msgstr "Oczekiwanie" msgid "Waiting for others" msgstr "Oczekiwanie na innych" -#: packages/lib/server-only/document/send-pending-email.ts +#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing." -msgstr "Oczekiwanie na innych, aby zakończyć podpisywanie." +msgstr "Oczekiwanie na zakończenie podpisywania przez innych." #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Waiting for others to sign" @@ -12966,7 +13010,7 @@ msgstr "Chcesz mieć profil publiczny?" #: apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx msgid "Warning, this email transport is currently being used by:" -msgstr "Uwaga, ten transport e-mail jest obecnie używany przez:" +msgstr "Ten dostawca poczty e-mail jest używany przez:" #: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx @@ -12995,7 +13039,7 @@ msgstr "Nie mogliśmy zaktualizować klucza dostępu. Spróbuj ponownie późnie #: apps/remix/app/utils/toast-error-messages.ts msgid "We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead." -msgstr "Nie udało się przekonwertować tego pliku. Sprawdź, czy jest to prawidłowy dokument programu Word, lub zamiast tego prześlij plik PDF." +msgstr "Nie możemy przekonwertować tego pliku. Sprawdź, czy plik Word jest prawidłowy lub prześlij plik PDF." #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "We couldn't create a Stripe customer. Please try again." @@ -13025,7 +13069,7 @@ msgstr "Nie mogliśmy zaktualizować dostawcy. Spróbuj ponownie." #: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx msgid "We encountered an error while attempting to delete this organisation. Please try again later." -msgstr "Wystąpił błąd podczas próby usunięcia tej organizacji. Spróbuj ponownie później." +msgstr "Wystąpił błąd podczas próby usunięcia organizacji. Spróbuj ponownie później." #: packages/lib/client-only/providers/envelope-editor-provider.tsx #: packages/lib/client-only/providers/envelope-editor-provider.tsx @@ -13177,7 +13221,7 @@ msgstr "Wystąpił nieznany błąd podczas próby zaktualizowania baneru. Sprób #: apps/remix/app/components/general/admin-email-blocklist-section.tsx msgid "We encountered an unknown error while attempting to update the email blocklist. Please try again later." -msgstr "Wystąpił nieznany błąd podczas próby zaktualizowania listy blokowanych adresów e-mail. Spróbuj ponownie później." +msgstr "Wystąpił nieznany błąd podczas próby zaktualizowania listy zablokowanych domen. Spróbuj ponownie później." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "We encountered an unknown error while attempting to update the envelope. Please try again later." @@ -13258,7 +13302,7 @@ msgstr "Nie mogliśmy Cię wylogować." #: apps/remix/app/routes/_recipient+/report.$token.tsx msgid "We were unable to report this sender at this time. Please try again later." -msgstr "Nie mogliśmy zgłosić tego nadawcy w tym momencie. Spróbuj ponownie później." +msgstr "Nie mogliśmy zgłosić nadawcę. Spróbuj ponownie później." #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx msgid "We were unable to set your public profile to public. Please try again." @@ -13344,7 +13388,7 @@ msgstr "Pusto" #: packages/email/template-components/template-document-pending.tsx msgid "We're still waiting for other signers to sign this document.<0/>We'll notify you as soon as it's ready." -msgstr "Wciąż czekamy na podpisy pozostałych podpisujących.<0/>Powiadomimy Cię, gdy dokument będzie gotowy." +msgstr "Wciąż czekamy na podpisy pozostałych osób.<0/>Powiadomimy Cię, gdy dokument będzie gotowy." #: packages/email/templates/reset-password.tsx msgid "We've changed your password as you asked. You can now sign in with your new password." @@ -13352,15 +13396,15 @@ msgstr "Zmieniliśmy Twoje hasło. Możesz zalogować się za pomocą nowego has #: packages/email/templates/organisation-limit-alert.tsx msgid "We've noticed API activity on your account that exceeds the fair use limits of your current plan. As a precaution, new API activity has been temporarily paused pending review." -msgstr "Zauważyliśmy aktywność API na Twoim koncie, która przekracza limity uczciwego korzystania w Twoim obecnym planie. W ramach środka ostrożności nowa aktywność API została tymczasowo wstrzymana do czasu weryfikacji." +msgstr "Zauważyliśmy na Twoim koncie aktywność API, która przekracza limity dozwolonego użytkowania w ramach obecnego planu. Nowa aktywność API została tymczasowo wstrzymana do czasu przeprowadzenia weryfikacji." #: packages/email/templates/organisation-limit-alert.tsx msgid "We've noticed document activity on your account that exceeds the fair use limits of your current plan. As a precaution, new document activity has been temporarily paused pending review." -msgstr "Zauważyliśmy aktywność związaną z dokumentami na Twoim koncie, która przekracza limity uczciwego korzystania w Twoim obecnym planie. W ramach środka ostrożności nowa aktywność związana z dokumentami została tymczasowo wstrzymana do czasu weryfikacji." +msgstr "Zauważyliśmy na Twoim koncie aktywność dokumentów, która przekracza limity dozwolonego użytkowania w ramach obecnego planu. Nowa aktywność została tymczasowo wstrzymana do czasu przeprowadzenia weryfikacji." #: packages/email/templates/organisation-limit-alert.tsx msgid "We've noticed email sending activity on your account that exceeds the fair use limits of your current plan. As a precaution, new email activity has been temporarily paused pending review." -msgstr "Zauważyliśmy wysyłkę e‑maili z Twojego konta, która przekracza limity uczciwego korzystania w Twoim obecnym planie. W ramach środka ostrożności nowa aktywność e‑mailowa została tymczasowo wstrzymana do czasu weryfikacji." +msgstr "Zauważyliśmy na Twoim koncie aktywność wiadomości, która przekracza limity dozwolonego użytkowania w ramach obecnego planu. Nowa aktywność została tymczasowo wstrzymana do czasu przeprowadzenia weryfikacji." #: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx msgid "We've sent a 6-digit verification code to your email. Please enter it below to complete the document." @@ -13459,7 +13503,7 @@ msgstr "Podpisujący mogą wybrać, kto powinien podpisać jako następny w kole #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation." -msgstr "Użytkownicy logujący się po raz pierwszy za pomocą logowania SSO otrzymają również własną osobistą organizację." +msgstr "Po włączeniu tej opcji użytkownicy logujący się po raz pierwszy za pomocą SSO otrzymają również własną osobistą organizację." #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "When you click continue, you will be prompted to add the first available authenticator on your system." @@ -13490,10 +13534,18 @@ msgstr "Własny branding, nieograniczona liczba użytkowników i więcej" msgid "Width:" msgstr "Szerokość:" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Window" +msgstr "" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Withdrawing Consent" msgstr "Wycofanie zgody" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Within limit" +msgstr "" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" msgstr "Wpisz opis, który będzie wyświetlany w profilu publicznym" @@ -13531,7 +13583,7 @@ msgstr "Zatwierdziłeś dokument" #: apps/remix/app/components/dialogs/envelope-cancel-dialog.tsx msgid "You are about to cancel <0>\"{title}\"" -msgstr "Zamierzasz anulować dokument <0>\"{title}\"" +msgstr "Zamierzasz anulować dokument <0>„{title}”" #: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx msgid "You are about to complete approving the following document" @@ -13560,7 +13612,7 @@ msgstr "Zamierzasz usunąć organizację <0>{0}. Wszystkie dane powiązane z #: apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx msgid "You are about to delete <0>{organisationName}. This action is not reversible. All teams will be removed and all documents will be orphaned to the deleted-account service account." -msgstr "Za chwilę usuniesz <0>{organisationName}. Tej operacji nie można cofnąć. Wszystkie zespoły zostaną usunięte, a wszystkie dokumenty zostaną przypisane do konta usługi usuniętego konta." +msgstr "Zamierzasz usunąć organizację <0>{organisationName}. Ta akcja jest nieodwracalna. Wszystkie zespoły zostaną usunięcie, a dokumenty zostaną przypisane do konta serwisowego usuniętego konta." #: apps/remix/app/components/dialogs/team-email-delete-dialog.tsx msgid "You are about to delete the following team email from <0>{teamName}." @@ -13710,11 +13762,11 @@ msgstr "Nie masz uprawnień do zresetowania weryfikacji dwuetapowej tego użytko #: packages/lib/utils/document-audit-logs.ts msgid "You authenticated with the signing provider" -msgstr "Uwierzytelniłeś(-aś) się u dostawcy usługi podpisu" +msgstr "Uwierzytelniłeś się u dostawcy podpisu" #: packages/lib/utils/document-audit-logs.ts msgid "You authorised the remote signature" -msgstr "Autoryzowałeś(-aś) zdalny podpis" +msgstr "Autoryzowałeś podpis zdalny" #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx msgid "You can add fields manually in the editor." @@ -13730,7 +13782,7 @@ msgstr "Możesz także skopiować i wkleić link do przeglądarki: {confirmation #: 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)" +msgstr "Możesz także skopiować i wkleić 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." @@ -13809,11 +13861,11 @@ msgstr "Nie możesz edytować użytkownika zespołu, który ma wyższą rolę ni #: apps/remix/app/components/dialogs/team-member-delete-dialog.tsx msgid "You cannot remove a member with a role higher than your own." -msgstr "Nie możesz usunąć członka, który ma wyższą rolę niż Ty." +msgstr "Nie możesz usunąć użytkownika o roli wyższej niż Twoja." #: apps/remix/app/components/dialogs/team-member-delete-dialog.tsx msgid "You cannot remove members from this team while the inherit member feature is enabled." -msgstr "Nie możesz usuwać członków z tego zespołu, gdy funkcja dziedziczenia członków jest włączona." +msgstr "Nie możesz usunąć użytkowników zespołu, jeśli funkcja odziedziczenia użytkownika jest włączona." #: apps/remix/app/components/dialogs/team-member-delete-dialog.tsx msgid "You cannot remove the organisation owner from the team." @@ -13916,8 +13968,7 @@ msgstr "Dołącz do organizacji {0} w Documenso" msgid "You have been invited to join the following organisation" msgstr "Masz zaproszenie do dołączenia do następującej organizacji" -#: packages/lib/server-only/recipient/delete-envelope-recipient.ts -#: packages/lib/server-only/recipient/set-document-recipients.ts +#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts msgid "You have been removed from a document" msgstr "Usunięto Cię z dokumentu" @@ -13934,7 +13985,7 @@ msgstr "Odrzucono zaproszenie dołączenia do organizacji <0>{0}." #: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts #: packages/lib/server-only/document/resend-document.ts msgid "You have initiated the document {0} that requires you to {recipientActionVerb} it." -msgstr "Sprawdź i {recipientActionVerb} dokument utworzony przez Ciebie." +msgstr "Sprawdź i {recipientActionVerb} dokument „{0}” utworzony przez Ciebie." #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "You have no webhooks yet. Your webhooks will be shown here once you create them." @@ -13967,7 +14018,7 @@ msgstr "Osiągnięto maksymalną miesięczną liczbę dokumentów. Zaktualizuj p #: apps/remix/app/utils/toast-error-messages.ts msgid "You have reached your document limit for this plan. Please upgrade your plan." -msgstr "Osiągnięto limit liczby dokumentów w tym planie. Zaktualizuj swój plan." +msgstr "Osiągnięto maksymalną liczbę dokumentów. Zaktualizuj plan." #: apps/remix/app/components/general/document/document-upload-button-legacy.tsx #: apps/remix/app/components/general/envelope/envelope-upload-button.tsx @@ -14037,6 +14088,10 @@ msgstr "Dostęp został unieważniony." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "Masz prawo wycofać swoją zgodę na używanie podpisów elektronicznych w dowolnym momencie przed zakończeniem procesu podpisywania. Aby wycofać zgodę, skontaktuj się z nadawcą dokumentu. Jeśli nie możesz skontaktować się z nadawcą, napisz do nas na adres <0>{SUPPORT_EMAIL}. Pamiętaj, że wycofanie zgody może opóźnić lub wstrzymać realizację danej transakcji lub usługi." +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "You have unsaved changes" +msgstr "" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You have updated {memberName}." msgstr "Użytkownik {memberName} został zaktualizowany." @@ -14201,7 +14256,7 @@ msgstr "Poprosiłeś o kod weryfikacyjny dla dokumentu" #: packages/lib/utils/document-audit-logs.ts msgid "You requested a remote signature" -msgstr "Zleciłeś(-aś) wykonanie zdalnego podpisu" +msgstr "Poprosiłeś o podpis zdalny" #. placeholder {0}: data.recipientEmail #: packages/lib/utils/document-audit-logs.ts @@ -14414,7 +14469,7 @@ msgstr "Dokument został usunięty przez administratora!" #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx msgid "Your document has been resent successfully." -msgstr "Twój dokument został pomyślnie ponownie wysłany." +msgstr "Dokument został ponownie wysłany." #: apps/remix/app/components/dialogs/envelope-save-as-template-dialog.tsx msgid "Your document has been saved as a template." @@ -14511,7 +14566,7 @@ msgstr "Organizacja została utworzona." #: packages/email/templates/organisation-delete.tsx #: packages/email/templates/organisation-delete.tsx msgid "Your organisation has been deleted" -msgstr "Twoja organizacja została usunięta" +msgstr "Organizacja została usunięta" #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx msgid "Your organisation has been successfully deleted." @@ -14523,47 +14578,47 @@ msgstr "Organizacja została zaktualizowana." #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx msgid "Your organisation has exceeded a fair use limit" -msgstr "Twoja organizacja przekroczyła limit uczciwego użytkowania." +msgstr "Organizacja przekroczyła limit dozwolonego użytkowania" #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx msgid "Your organisation has exceeded a fair use limit. Please contact <0>support to review your plan's limits." -msgstr "Twoja organizacja przekroczyła limit uczciwego użytkowania. Skontaktuj się z <0>pomocą techniczną, aby przejrzeć limity swojego planu." +msgstr "Organizacja przekroczyła limit dozwolonego użytkowania. Skontaktuj się z <0>pomocą techniczną, aby przeprowadzić weryfikację." #: apps/remix/app/utils/toast-error-messages.ts msgid "Your organisation has reached its plan's fair use limit. Please contact your organisation administrator or support to continue." -msgstr "Twoja organizacja osiągnęła limit uczciwego użytkowania w swoim planie. Skontaktuj się z administratorem organizacji lub pomocą techniczną, aby kontynuować." +msgstr "Organizacja przekroczyła limit dozwolonego użytkowania. Skontaktuj się z administratorem organizacji." #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx msgid "Your organisation is approaching a fair use limit" -msgstr "Twoja organizacja zbliża się do limitu dozwolonego użytkowania (fair use)" +msgstr "Organizacja zbliża się do limitu dozwolonego użytkowania" #: apps/remix/app/components/general/organisations/organisation-quota-banner.tsx msgid "Your organisation is approaching a fair use limit. If you expect to need higher limits, please contact <0>support to review your plan's limits." -msgstr "Twoja organizacja zbliża się do limitu dozwolonego użytkowania (fair use). Jeśli spodziewasz się potrzeby wyższych limitów, skontaktuj się z <0>pomocą techniczną, aby omówić limity w Twoim planie." +msgstr "Organizacja zbliża się do limitu dozwolonego użytkowania. Jeśli potrzebujesz wyższych limitów, skontaktuj się z <0>pomocą techniczną." #: packages/email/templates/organisation-limit-alert.tsx msgid "Your organisation is generating API requests faster than normal, so some requests are being temporarily throttled." -msgstr "Twoja organizacja generuje żądania API szybciej niż zwykle, więc część żądań jest tymczasowo ograniczana (throttling)." +msgstr "Twoja organizacja generuje żądania API szybciej niż zwykle, więc niektóre żądania zostały tymczasowo ograniczane." #: packages/email/templates/organisation-limit-alert.tsx msgid "Your organisation is generating documents faster than normal, so some requests are being temporarily throttled." -msgstr "Twoja organizacja generuje dokumenty szybciej niż zwykle, więc część żądań jest tymczasowo ograniczana (throttling)." +msgstr "Twoja organizacja generuje dokumenty szybciej niż zwykle, więc niektóre żądania zostały tymczasowo ograniczane." #: packages/email/templates/organisation-limit-alert.tsx msgid "Your organisation is generating emails faster than normal, so some requests are being temporarily throttled." -msgstr "Twoja organizacja generuje e‑maile szybciej niż zwykle, więc część żądań jest tymczasowo ograniczana (throttling)." +msgstr "Twoja organizacja generuje wiadomości szybciej niż zwykle, więc niektóre żądania zostały tymczasowo ograniczane." #: packages/email/templates/organisation-limit-alert.tsx msgid "Your organisation is nearing its fair use limits for creating documents on your current plan. Once the limit is reached, new document activity will be temporarily paused." -msgstr "Twoja organizacja zbliża się do limitów dozwolonego użytkowania (fair use) w zakresie tworzenia dokumentów w ramach obecnego planu. Po osiągnięciu limitu nowe działania związane z dokumentami zostaną tymczasowo wstrzymane." +msgstr "Organizacja zbliża się do limitu dozwolonego użytkowania dokumentów. Po przekroczeniu limitu tworzenie nowych dokumentów zostanie wstrzymane." #: packages/email/templates/organisation-limit-alert.tsx msgid "Your organisation is nearing its fair use limits for making API requests on your current plan. Once the limit is reached, new API activity will be temporarily paused." -msgstr "Twoja organizacja zbliża się do limitów dozwolonego użytkowania (fair use) w zakresie wykonywania zapytań API w ramach obecnego planu. Po osiągnięciu limitu nowa aktywność API zostanie tymczasowo wstrzymana." +msgstr "Organizacja zbliża się do limitu dozwolonego użytkowania API. Po przekroczeniu limitu nowe żądania API zostaną wstrzymane." #: packages/email/templates/organisation-limit-alert.tsx msgid "Your organisation is nearing its fair use limits for sending email on your current plan. Once the limit is reached, new email activity will be temporarily paused." -msgstr "Twoja organizacja zbliża się do limitów dozwolonego użytkowania (fair use) w zakresie wysyłania e‑maili w ramach obecnego planu. Po osiągnięciu limitu nowa aktywność e‑mail zostanie tymczasowo wstrzymana." +msgstr "Organizacja zbliża się do limitu dozwolonego użytkowania wiadomości. Po przekroczeniu limitu tworzenie nowych wiadomości zostanie wstrzymane." #: apps/remix/app/components/forms/password.tsx #: apps/remix/app/components/forms/reset-password.tsx @@ -14613,27 +14668,27 @@ msgstr "To są Twoje kody odzyskiwania. Przechowuj je w bezpiecznym miejscu." #: packages/lib/utils/document-audit-logs.ts msgid "Your remote signature was applied" -msgstr "Twój zdalny podpis został zastosowany" +msgstr "Złożyłeś podpis zdalny" #: apps/remix/app/components/general/document-signing/csc-recipient-signing-in-progress-page.tsx msgid "Your signing authorisation expired before the signature could be applied. Please reauthorise to retry." -msgstr "Twoje upoważnienie do podpisu wygasło, zanim podpis mógł zostać złożony. Aby spróbować ponownie, ponownie udziel upoważnienia." +msgstr "Autoryzacja podpisu wygasła przed jego złożeniem. Spróbuj ponownie." #: apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx msgid "Your signing certificate is invalid, expired, or missing a required key. Contact your administrator or signing provider for assistance." -msgstr "Twój certyfikat podpisu jest nieprawidłowy, wygasł lub brakuje w nim wymaganego klucza. Skontaktuj się z administratorem lub dostawcą usługi podpisu, aby uzyskać pomoc." +msgstr "Certyfikat podpisu jest nieważny, wygasł lub brakuje w nim wymaganego klucza. Skontaktuj się z administratorem lub dostawcą podpisu." #: packages/lib/utils/document-audit-logs.ts msgid "Your signing provider authentication failed" -msgstr "Uwierzytelnianie u Twojego dostawcy usługi podpisu nie powiodło się" +msgstr "Twoje uwierzytelnienie u dostawcy podpisu nie powiodło się" #: apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx msgid "Your signing provider does not advertise a signing algorithm this document accepts. Contact your administrator or signing provider for assistance." -msgstr "Twój dostawca usługi podpisu nie udostępnia algorytmu podpisu akceptowanego przez ten dokument. Skontaktuj się z administratorem lub dostawcą usługi podpisu, aby uzyskać pomoc." +msgstr "Dostawca podpisu nie obsługuje wymaganego algorytmu podpisywania. Skontaktuj się z administratorem lub dostawcą podpisu." #: apps/remix/app/components/general/document-signing/csc-recipient-blocked-page.tsx msgid "Your signing provider returned no usable credentials for this account. Contact your administrator or signing provider for assistance." -msgstr "Twój dostawca usługi podpisu nie zwrócił żadnych użytecznych poświadczeń dla tego konta. Skontaktuj się z administratorem lub dostawcą usługi podpisu, aby uzyskać pomoc." +msgstr "Dostawca podpisu nie zwrócił żadnych użytecznych certyfikatów. Skontaktuj się z administratorem lub dostawcą podpisu." #: apps/remix/app/components/embed/embed-recipient-expired.tsx msgid "Your signing window for this document has expired. Please contact the sender for a new invitation." @@ -14662,7 +14717,7 @@ msgstr "Twój szablon został pomyślnie utworzony" #: apps/remix/app/components/dialogs/envelope-redistribute-dialog.tsx msgid "Your template has been resent successfully." -msgstr "Twój szablon został pomyślnie ponownie wysłany." +msgstr "Szablon został ponownie wysłany." #: apps/remix/app/components/dialogs/envelope-duplicate-dialog.tsx msgid "Your template has been successfully duplicated." @@ -14715,5 +14770,4 @@ msgstr "Twój kod weryfikacyjny:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" -msgstr "your-domain.com another-domain.com" - +msgstr "twoja-domena.pl inna-domena.pl" diff --git a/packages/lib/translations/pt-BR/web.po b/packages/lib/translations/pt-BR/web.po index 85490df57..2bf5edf61 100644 --- a/packages/lib/translations/pt-BR/web.po +++ b/packages/lib/translations/pt-BR/web.po @@ -1411,7 +1411,7 @@ msgid "Add Placeholders" msgstr "Adicionar Espaços Reservados" #: apps/remix/app/components/general/rate-limit-array-input.tsx -msgid "Add rate limit" +msgid "Add rate limit window" msgstr "" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx @@ -2003,6 +2003,7 @@ msgstr "Qualquer Origem" msgid "Any Status" msgstr "Qualquer Status" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "API" msgstr "" @@ -2012,10 +2013,6 @@ msgstr "" msgid "API key" msgstr "" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "API rate limits" -msgstr "" - #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "API requests" msgstr "" @@ -2436,6 +2433,7 @@ msgstr "Logo da Marca" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding Preferences" msgstr "Preferências da Marca" @@ -2675,6 +2673,10 @@ msgstr "Não é possível remover o signatário" msgid "Cannot upload items after the document has been sent" msgstr "Não é possível fazer upload de itens após o envio do documento" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Capabilities enabled for this organisation." +msgstr "" + #: packages/lib/constants/recipient-roles.ts msgctxt "Recipient role name" msgid "Cc" @@ -3567,6 +3569,7 @@ msgid "Currently all organisation members can access this team" msgstr "Atualmente, todos os membros da organização podem acessar esta equipe" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "Atualmente, a marca só pode ser configurada para planos Teams e superiores." @@ -4209,8 +4212,8 @@ msgstr "" #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts -#: packages/lib/server-only/document/delete-document.ts msgid "Document Cancelled" msgstr "Documento Cancelado" @@ -4400,10 +4403,6 @@ msgstr "Preferências de Documento" msgid "Document preferences updated" msgstr "Preferências de documento atualizadas" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Document rate limits" -msgstr "" - #: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document rejected" @@ -4539,6 +4538,7 @@ msgstr "Documentação" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-nav-desktop.tsx #: apps/remix/app/components/general/app-nav-mobile.tsx +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -4988,10 +4988,6 @@ msgstr "Preferências de E-mail" msgid "Email preferences updated" msgstr "Preferências de e-mail atualizadas" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Email rate limits" -msgstr "" - #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email recipients when a pending document is deleted" msgstr "" @@ -5087,6 +5083,7 @@ msgstr "A verificação de e-mail foi removida" msgid "Email verification has been resent" msgstr "A verificação de e-mail foi reenviada" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -5100,16 +5097,14 @@ msgstr "E-mails" msgid "Embedding, 5 members included and more" msgstr "Incorporação, 5 membros incluídos e mais" -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Empty = Unlimited, 0 = Blocked" -msgstr "" - #: packages/ui/primitives/document-flow/add-fields.tsx msgid "Empty field" msgstr "Campo vazio" +#: apps/remix/app/components/general/claim-limit-fields.tsx +msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." +msgstr "" + #: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx msgid "Enable" msgstr "Ativar" @@ -5229,6 +5224,10 @@ msgstr "" msgid "Enter" msgstr "" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a max request count greater than 0" +msgstr "" + #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." msgstr "Digite um nome para sua nova pasta. As pastas ajudam você a organizar seus itens." @@ -5237,6 +5236,10 @@ msgstr "Digite um nome para sua nova pasta. As pastas ajudam você a organizar s msgid "Enter a new title" msgstr "" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a window, e.g. 5m" +msgstr "" + #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Enter claim name" msgstr "Digite o nome da reivindicação" @@ -5495,6 +5498,10 @@ msgstr "Todos assinaram" msgid "Everyone has signed! You will receive an email copy of the signed document." msgstr "Todos assinaram! Você receberá uma cópia do documento assinado por e-mail." +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Exceeded" +msgstr "" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Exceeded timeout" msgstr "Tempo limite excedido" @@ -6758,6 +6765,10 @@ msgstr "Modo Claro" msgid "Like to have your own public profile with agreements?" msgstr "Gostaria de ter seu próprio perfil público com contratos?" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Limit reached" +msgstr "" + #: apps/remix/app/components/general/claim-limit-fields.tsx msgid "Limits" msgstr "" @@ -7063,6 +7074,10 @@ msgstr "MAU (entrou)" msgid "Max" msgstr "Máx" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Max requests" +msgstr "" + #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." msgstr "Tamanho máximo do arquivo: 4MB. Máximo de 100 linhas por upload. Valores em branco usarão os padrões do modelo." @@ -7109,12 +7124,12 @@ msgstr "Membro Desde" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -7183,15 +7198,11 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "Usuários Ativos Mensais: Usuários que tiveram pelo menos um de seus documentos concluídos" #: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly API quota" +msgid "Monthly quota" msgstr "" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly document quota" -msgstr "" - -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly email quota" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Monthly usage" msgstr "" #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx @@ -7283,7 +7294,6 @@ msgid "Name" msgstr "Nome" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Name is required" msgstr "O nome é obrigatório" @@ -7291,6 +7301,10 @@ msgstr "O nome é obrigatório" msgid "Name Settings" msgstr "Configurações de Nome" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Near limit" +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Need to sign documents?" msgstr "Precisa assinar documentos?" @@ -7430,6 +7444,10 @@ msgstr "Nenhuma ação adicional é necessária de sua parte neste momento." msgid "No groups found" msgstr "" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "No inherited claim" +msgstr "" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No License Configured" msgstr "" @@ -7937,6 +7955,11 @@ msgstr "Original" msgid "Otherwise, the document will be created as a draft." msgstr "Caso contrário, o documento será criado como um rascunho." +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Overlapping fields detected" +msgstr "" + #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Override organisation settings" @@ -8141,6 +8164,10 @@ msgstr "" msgid "Pending since" msgstr "" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "People with access to this organisation." +msgstr "" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -8303,10 +8330,6 @@ msgstr "Por favor, insira um nome significativo para seu token. Isso ajudará vo msgid "Please enter a number" msgstr "" -#: apps/remix/app/components/general/claim-account.tsx -msgid "Please enter a valid name." -msgstr "Por favor, insira um nome válido." - #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Please enter a valid number" msgstr "Por favor, insira um número válido" @@ -8772,6 +8795,10 @@ msgstr "" msgid "Recipient rejected the document" msgstr "" +#: packages/lib/utils/document-audit-logs.ts +msgid "Recipient rejected the document externally" +msgstr "" + #: apps/remix/app/components/general/admin-global-settings-section.tsx msgid "Recipient removed" msgstr "" @@ -8908,6 +8935,11 @@ msgstr "URL de Redirecionamento" msgid "Redirecting" msgstr "Redirecionando" +#. placeholder {0}: oidcProviderLabel || 'OIDC' +#: apps/remix/app/routes/_unauthenticated+/signin.tsx +msgid "Redirecting to {0}..." +msgstr "" + #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx msgid "Registration Successful" @@ -9036,6 +9068,10 @@ msgstr "Remover membro da organização" msgid "Remove Organisation Member" msgstr "" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Remove rate limit" +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Remove recipient" msgstr "Remover destinatário" @@ -9183,9 +9219,7 @@ msgstr "" msgid "Resend verification" msgstr "Reenviar verificação" -#: apps/remix/app/components/forms/organisation-update-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx -#: apps/remix/app/components/forms/team-update-form.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Reset" msgstr "Redefinir" @@ -9228,6 +9262,10 @@ msgstr "Resolver" msgid "Resolve payment" msgstr "Resolver pagamento" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Resource blocked" +msgstr "" + #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Response" msgstr "Resposta" @@ -9370,6 +9408,7 @@ msgid "Save as Template" msgstr "" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx msgid "Save changes" msgstr "" @@ -9803,6 +9842,10 @@ msgstr "Enviando..." msgid "Sent" msgstr "Enviado" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Sent this period" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" msgstr "Sessão revogada" @@ -10210,6 +10253,11 @@ msgstr "Configurações do Site" msgid "Skip" msgstr "Pular" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." +msgstr "" + #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "Alguns signatários não receberam um campo de assinatura. Por favor, atribua pelo menos 1 campo de assinatura a cada signatário antes de prosseguir." @@ -10780,10 +10828,10 @@ msgid "Team URL" msgstr "URL da Equipe" #: apps/remix/app/components/general/org-menu-switcher.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx @@ -10794,6 +10842,10 @@ msgstr "Equipes" msgid "Teams help you organise your work and collaborate with others. Create your first team to get started." msgstr "Equipes ajudam você a organizar seu trabalho e colaborar com outros. Crie sua primeira equipe para começar." +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Teams that belong to this organisation." +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Teams that this organisation group is currently assigned to" msgstr "Equipes às quais este grupo da organização está atualmente atribuído" @@ -11088,6 +11140,23 @@ msgstr "" msgid "The document was created but could not be sent to recipients." msgstr "O documento foi criado, mas não pôde ser enviado aos destinatários." +#: packages/lib/utils/document-audit-logs.ts +msgid "The document was rejected externally by {onBehalfOf} on behalf of {user}" +msgstr "" + +#: packages/lib/utils/document-audit-logs.ts +#: packages/lib/utils/document-audit-logs.ts +msgid "The document was rejected externally by {onBehalfOf} on behalf of the recipient" +msgstr "" + +#: packages/lib/utils/document-audit-logs.ts +msgid "The document was rejected externally on behalf of {user}" +msgstr "" + +#: packages/lib/utils/document-audit-logs.ts +msgid "The document was rejected externally on behalf of the recipient" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx msgid "The document will be hidden from your account" msgstr "O documento será ocultado da sua conta" @@ -12223,6 +12292,7 @@ msgstr "Não autorizado" msgid "Uncompleted" msgstr "Não concluído" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "" @@ -12254,8 +12324,6 @@ msgstr "Nome desconhecido" #: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Unlimited" msgstr "Ilimitado" @@ -12272,6 +12340,10 @@ msgstr "Desvincular" msgid "Unpin" msgstr "Desafixar" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "Unsaved changes" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" @@ -12291,9 +12363,6 @@ msgstr "Grupo sem título" #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx -#: apps/remix/app/components/forms/branding-preferences-form.tsx -#: apps/remix/app/components/forms/document-preferences-form.tsx -#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -12316,6 +12385,7 @@ msgstr "Atualizar Banner" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Update Billing" msgstr "Atualizar Faturamento" @@ -12343,10 +12413,6 @@ msgstr "Atualizar e-mail" msgid "Update Fields" msgstr "Atualizar Campos" -#: apps/remix/app/components/forms/organisation-update-form.tsx -msgid "Update organisation" -msgstr "Atualizar organização" - #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -12382,10 +12448,6 @@ msgstr "Atualizar função" msgid "Update Subscription Claim" msgstr "Atualizar Reivindicação de Assinatura" -#: apps/remix/app/components/forms/team-update-form.tsx -msgid "Update team" -msgstr "Atualizar equipe" - #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx msgid "Update team email" @@ -12554,15 +12616,18 @@ msgstr "Enviando" msgid "URL" msgstr "URL" -#. placeholder {0}: selectedStat?.period || 'N/A' -#: apps/remix/app/components/general/organisation-usage-panel.tsx -msgid "Usage for period: {0}" -msgstr "" - #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx msgid "Use" msgstr "Usar" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h" +msgstr "" + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a unique window for each rate limit" +msgstr "" + #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx msgid "Use Authenticator" @@ -12916,7 +12981,7 @@ msgstr "Aguardando" msgid "Waiting for others" msgstr "Aguardando outros" -#: packages/lib/server-only/document/send-pending-email.ts +#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing." msgstr "Aguardando outros completarem a assinatura." @@ -13464,10 +13529,18 @@ msgstr "Whitelabeling, membros ilimitados e mais" msgid "Width:" msgstr "" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Window" +msgstr "" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Withdrawing Consent" msgstr "Retirada de Consentimento" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Within limit" +msgstr "" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" msgstr "Escreva uma descrição para exibir em seu perfil público" @@ -13890,8 +13963,7 @@ msgstr "Você foi convidado para participar de {0} no Documenso" msgid "You have been invited to join the following organisation" msgstr "Você foi convidado para participar da seguinte organização" -#: packages/lib/server-only/recipient/delete-envelope-recipient.ts -#: packages/lib/server-only/recipient/set-document-recipients.ts +#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts msgid "You have been removed from a document" msgstr "Você foi removido de um documento" @@ -14011,6 +14083,10 @@ msgstr "Você revogou o acesso com sucesso." msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "Você tem o direito de retirar seu consentimento para usar assinaturas eletrônicas a qualquer momento antes de concluir o processo de assinatura. Para retirar seu consentimento, entre em contato com o remetente do documento. Se não conseguir entrar em contato com o remetente, você pode entrar em contato com <0>{SUPPORT_EMAIL} para obter assistência. Esteja ciente de que a retirada do consentimento pode atrasar ou interromper a conclusão da transação ou serviço relacionado." +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "You have unsaved changes" +msgstr "" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You have updated {memberName}." msgstr "Você atualizou {memberName}." diff --git a/packages/lib/translations/zh/web.po b/packages/lib/translations/zh/web.po index e107bc848..c28a8d965 100644 --- a/packages/lib/translations/zh/web.po +++ b/packages/lib/translations/zh/web.po @@ -1416,8 +1416,8 @@ msgid "Add Placeholders" msgstr "添加占位符" #: apps/remix/app/components/general/rate-limit-array-input.tsx -msgid "Add rate limit" -msgstr "添加速率限制" +msgid "Add rate limit window" +msgstr "" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Add recipients" @@ -2008,6 +2008,7 @@ msgstr "任意来源" msgid "Any Status" msgstr "任意状态" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx msgid "API" msgstr "API" @@ -2017,10 +2018,6 @@ msgstr "API" msgid "API key" msgstr "API 密钥" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "API rate limits" -msgstr "API 速率限制" - #: apps/remix/app/components/general/organisation-usage-panel.tsx msgid "API requests" msgstr "API 请求" @@ -2441,6 +2438,7 @@ msgstr "品牌 Logo" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Branding Preferences" msgstr "品牌偏好设置" @@ -2680,6 +2678,10 @@ msgstr "无法移除签署人" msgid "Cannot upload items after the document has been sent" msgstr "文档发送后无法再上传项目" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Capabilities enabled for this organisation." +msgstr "" + #: packages/lib/constants/recipient-roles.ts msgctxt "Recipient role name" msgid "Cc" @@ -3572,6 +3574,7 @@ msgid "Currently all organisation members can access this team" msgstr "目前所有组织成员都可以访问此团队" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Currently branding can only be configured for Teams and above plans." msgstr "目前仅 Teams 及以上套餐可以配置品牌。" @@ -4214,8 +4217,8 @@ msgstr "文档已被取消" #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx #: apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +#: packages/lib/jobs/definitions/emails/send-document-deleted-emails.handler.ts #: packages/lib/server-only/admin/admin-super-delete-document.ts -#: packages/lib/server-only/document/delete-document.ts msgid "Document Cancelled" msgstr "文档已取消" @@ -4405,10 +4408,6 @@ msgstr "文档偏好" msgid "Document preferences updated" msgstr "文档偏好设置已更新" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Document rate limits" -msgstr "文档速率限制" - #: apps/remix/app/components/general/document-signing/document-signing-reject-dialog.tsx #: apps/remix/app/components/general/document/document-status.tsx msgid "Document rejected" @@ -4544,6 +4543,7 @@ msgstr "文档" #: apps/remix/app/components/general/app-command-menu.tsx #: apps/remix/app/components/general/app-nav-desktop.tsx #: apps/remix/app/components/general/app-nav-mobile.tsx +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/general/skeletons/document-edit-skeleton.tsx @@ -4993,10 +4993,6 @@ msgstr "邮件偏好" msgid "Email preferences updated" msgstr "邮件偏好已更新" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Email rate limits" -msgstr "电子邮件速率限制" - #: packages/ui/components/document/document-email-checkboxes.tsx msgid "Email recipients when a pending document is deleted" msgstr "在待处理文档被删除时向收件人发送电子邮件通知" @@ -5092,6 +5088,7 @@ msgstr "邮箱验证已移除" msgid "Email verification has been resent" msgstr "验证邮件已重新发送" +#: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-stats-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx @@ -5105,16 +5102,14 @@ msgstr "邮箱" msgid "Embedding, 5 members included and more" msgstr "内嵌、包含 5 名成员等更多功能" -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Empty = Unlimited, 0 = Blocked" -msgstr "留空 = 不限,0 = 禁用" - #: packages/ui/primitives/document-flow/add-fields.tsx msgid "Empty field" msgstr "空字段" +#: apps/remix/app/components/general/claim-limit-fields.tsx +msgid "Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h." +msgstr "" + #: apps/remix/app/components/dialogs/team-inherit-member-enable-dialog.tsx msgid "Enable" msgstr "启用" @@ -5234,6 +5229,10 @@ msgstr "请确保您使用的是嵌入令牌,而不是 API 令牌" msgid "Enter" msgstr "输入" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a max request count greater than 0" +msgstr "" + #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." msgstr "为新文件夹输入名称。文件夹可以帮助您整理项目。" @@ -5242,6 +5241,10 @@ msgstr "为新文件夹输入名称。文件夹可以帮助您整理项目。" msgid "Enter a new title" msgstr "输入新标题" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Enter a window, e.g. 5m" +msgstr "" + #: apps/remix/app/components/forms/subscription-claim-form.tsx msgid "Enter claim name" msgstr "输入声明名称" @@ -5500,6 +5503,10 @@ msgstr "所有人都已签署" msgid "Everyone has signed! You will receive an email copy of the signed document." msgstr "所有人都已签署!您将收到一份已签署文档的电子邮件副本。" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Exceeded" +msgstr "" + #: apps/remix/app/components/dialogs/passkey-create-dialog.tsx msgid "Exceeded timeout" msgstr "超时" @@ -6763,6 +6770,10 @@ msgstr "浅色模式" msgid "Like to have your own public profile with agreements?" msgstr "想拥有属于自己的公开协议页面吗?" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Limit reached" +msgstr "" + #: apps/remix/app/components/general/claim-limit-fields.tsx msgid "Limits" msgstr "限制" @@ -7068,6 +7079,10 @@ msgstr "月活跃用户(已登录)" msgid "Max" msgstr "最大值" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Max requests" +msgstr "" + #: apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx msgid "Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults." msgstr "最大文件大小:4MB。每次上传最多 100 行。空值将使用模板默认值。" @@ -7114,12 +7129,12 @@ msgstr "加入时间" #: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx #: apps/remix/app/components/dialogs/team-member-create-dialog.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-groups-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/team-groups-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/teams.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -7188,16 +7203,12 @@ msgid "Monthly Active Users: Users that had at least one of their documents comp msgstr "月活跃用户:至少有一份文档被完成的用户" #: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly API quota" -msgstr "每月 API 配额" +msgid "Monthly quota" +msgstr "" -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly document quota" -msgstr "每月文档配额" - -#: apps/remix/app/components/general/claim-limit-fields.tsx -msgid "Monthly email quota" -msgstr "每月邮件配额" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Monthly usage" +msgstr "" #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -7288,7 +7299,6 @@ msgid "Name" msgstr "姓名" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx -#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Name is required" msgstr "名称为必填项" @@ -7296,6 +7306,10 @@ msgstr "名称为必填项" msgid "Name Settings" msgstr "姓名设置" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Near limit" +msgstr "" + #: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx msgid "Need to sign documents?" msgstr "需要签署文档?" @@ -7435,6 +7449,10 @@ msgstr "目前您无需再执行任何操作。" msgid "No groups found" msgstr "未找到群组" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "No inherited claim" +msgstr "" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No License Configured" msgstr "未配置许可证" @@ -7942,6 +7960,11 @@ msgstr "原始" msgid "Otherwise, the document will be created as a draft." msgstr "否则将把文档创建为草稿。" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Overlapping fields detected" +msgstr "" + #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/email-preferences-form.tsx msgid "Override organisation settings" @@ -8146,6 +8169,10 @@ msgstr "待处理的组织邀请" msgid "Pending since" msgstr "待处理起始时间" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "People with access to this organisation." +msgstr "" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -8308,10 +8335,6 @@ msgstr "请输入一个有意义的令牌名称,以便日后识别。" msgid "Please enter a number" msgstr "请输入一个数字" -#: apps/remix/app/components/general/claim-account.tsx -msgid "Please enter a valid name." -msgstr "请输入有效姓名。" - #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Please enter a valid number" msgstr "请输入有效的数字" @@ -8917,6 +8940,11 @@ msgstr "重定向 URL" msgid "Redirecting" msgstr "正在重定向" +#. placeholder {0}: oidcProviderLabel || 'OIDC' +#: apps/remix/app/routes/_unauthenticated+/signin.tsx +msgid "Redirecting to {0}..." +msgstr "" + #: apps/remix/app/components/forms/signup.tsx #: apps/remix/app/components/general/claim-account.tsx msgid "Registration Successful" @@ -9045,6 +9073,10 @@ msgstr "移除组织成员" msgid "Remove Organisation Member" msgstr "移除组织成员" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Remove rate limit" +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "Remove recipient" msgstr "移除收件人" @@ -9192,9 +9224,7 @@ msgstr "重新发送信封" msgid "Resend verification" msgstr "重新发送验证" -#: apps/remix/app/components/forms/organisation-update-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx -#: apps/remix/app/components/forms/team-update-form.tsx #: apps/remix/app/components/general/organisation-usage-reset-button.tsx msgid "Reset" msgstr "重置" @@ -9237,6 +9267,10 @@ msgstr "解决" msgid "Resolve payment" msgstr "解决付款问题" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Resource blocked" +msgstr "" + #: apps/remix/app/components/general/webhook-logs-sheet.tsx msgid "Response" msgstr "响应" @@ -9379,6 +9413,7 @@ msgid "Save as Template" msgstr "另存为模板" #: apps/remix/app/components/dialogs/email-transport-update-dialog.tsx +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx msgid "Save changes" msgstr "保存更改" @@ -9812,6 +9847,10 @@ msgstr "正在发送..." msgid "Sent" msgstr "已发送" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Sent this period" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx msgid "Session revoked" msgstr "会话已撤销" @@ -10219,6 +10258,11 @@ msgstr "站点设置" msgid "Skip" msgstr "跳过" +#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +msgid "Some fields are placed on top of each other. This may complicate the signing process or cause fields to not work as expected." +msgstr "" + #: packages/ui/primitives/document-flow/missing-signature-field-dialog.tsx msgid "Some signers have not been assigned a signature field. Please assign at least 1 signature field to each signer before proceeding." msgstr "部分签署人尚未被分配签名字段。请在继续前为每位签署人至少分配 1 个签名字段。" @@ -10789,10 +10833,10 @@ msgid "Team URL" msgstr "团队 URL" #: apps/remix/app/components/general/org-menu-switcher.tsx +#: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-organisation-overview-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx @@ -10803,6 +10847,10 @@ msgstr "团队" msgid "Teams help you organise your work and collaborate with others. Create your first team to get started." msgstr "团队帮助您组织工作并与他人协作。创建您的第一个团队以开始使用。" +#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +msgid "Teams that belong to this organisation." +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx msgid "Teams that this organisation group is currently assigned to" msgstr "当前已将此组织组分配给以下团队" @@ -12249,6 +12297,7 @@ msgstr "未授权" msgid "Uncompleted" msgstr "未完成" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx #: packages/ui/primitives/signature-pad/signature-pad-draw.tsx msgid "Undo" msgstr "撤销" @@ -12280,8 +12329,6 @@ msgstr "未知姓名" #: apps/remix/app/components/general/claim-limit-fields.tsx #: apps/remix/app/components/general/organisation-usage-panel.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx -#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "Unlimited" msgstr "不限" @@ -12298,6 +12345,10 @@ msgstr "取消关联" msgid "Unpin" msgstr "取消固定" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "Unsaved changes" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" @@ -12317,9 +12368,6 @@ msgstr "未命名组" #: apps/remix/app/components/dialogs/team-group-update-dialog.tsx #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx #: apps/remix/app/components/dialogs/webhook-edit-dialog.tsx -#: apps/remix/app/components/forms/branding-preferences-form.tsx -#: apps/remix/app/components/forms/document-preferences-form.tsx -#: apps/remix/app/components/forms/email-preferences-form.tsx #: apps/remix/app/components/forms/public-profile-form.tsx #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx #: apps/remix/app/components/tables/admin-claims-table.tsx @@ -12342,6 +12390,7 @@ msgstr "更新横幅" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.branding.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx msgid "Update Billing" msgstr "更新计费" @@ -12369,10 +12418,6 @@ msgstr "更新邮箱" msgid "Update Fields" msgstr "更新字段" -#: apps/remix/app/components/forms/organisation-update-form.tsx -msgid "Update organisation" -msgstr "更新组织" - #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx #: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx @@ -12408,10 +12453,6 @@ msgstr "更新角色" msgid "Update Subscription Claim" msgstr "更新订阅声明" -#: apps/remix/app/components/forms/team-update-form.tsx -msgid "Update team" -msgstr "更新团队" - #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx #: apps/remix/app/components/dialogs/team-email-update-dialog.tsx msgid "Update team email" @@ -12580,15 +12621,18 @@ msgstr "正在上传" msgid "URL" msgstr "URL" -#. placeholder {0}: selectedStat?.period || 'N/A' -#: apps/remix/app/components/general/organisation-usage-panel.tsx -msgid "Usage for period: {0}" -msgstr "周期用量:{0}" - #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx msgid "Use" msgstr "使用" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a duration with a unit, e.g. 5m, 1h, or 24h" +msgstr "" + +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Use a unique window for each rate limit" +msgstr "" + #: apps/remix/app/components/forms/2fa/disable-authenticator-app-dialog.tsx #: apps/remix/app/components/forms/signin.tsx msgid "Use Authenticator" @@ -12942,7 +12986,7 @@ msgstr "等待中" msgid "Waiting for others" msgstr "等待其他人" -#: packages/lib/server-only/document/send-pending-email.ts +#: packages/lib/jobs/definitions/emails/send-document-pending-email.handler.ts msgid "Waiting for others to complete signing." msgstr "正在等待其他人完成签署。" @@ -13490,10 +13534,18 @@ msgstr "白标、自定义域、无限成员等更多功能" msgid "Width:" msgstr "宽度:" +#: apps/remix/app/components/general/rate-limit-array-input.tsx +msgid "Window" +msgstr "" + #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Withdrawing Consent" msgstr "撤回同意" +#: apps/remix/app/components/general/organisation-usage-panel.tsx +msgid "Within limit" +msgstr "" + #: apps/remix/app/components/forms/public-profile-form.tsx msgid "Write a description to display on your public profile" msgstr "撰写将在您的公共主页上展示的简介" @@ -13916,8 +13968,7 @@ msgstr "您已被邀请加入 Documenso 上的 {0}" msgid "You have been invited to join the following organisation" msgstr "您已被邀请加入以下组织" -#: packages/lib/server-only/recipient/delete-envelope-recipient.ts -#: packages/lib/server-only/recipient/set-document-recipients.ts +#: packages/lib/jobs/definitions/emails/send-recipient-removed-email.handler.ts msgid "You have been removed from a document" msgstr "您已被从某个文档中移除" @@ -14037,6 +14088,10 @@ msgstr "你已成功撤销访问权限。" msgid "You have the right to withdraw your consent to use electronic signatures at any time before completing the signing process. To withdraw your consent, please contact the sender of the document. In failing to contact the sender you may reach out to <0>{SUPPORT_EMAIL} for assistance. Be aware that withdrawing consent may delay or halt the completion of the related transaction or service." msgstr "在完成签署流程之前,你有权随时撤回对使用电子签名的同意。要撤回同意,请联系文档的发送方。如果无法联系发送方,你可以通过 <0>{SUPPORT_EMAIL} 与我们联系以获得协助。请注意,撤回同意可能会延迟或中止相关交易或服务的完成。" +#: apps/remix/app/components/forms/form-sticky-save-bar.tsx +msgid "You have unsaved changes" +msgstr "" + #: apps/remix/app/components/dialogs/team-member-update-dialog.tsx msgid "You have updated {memberName}." msgstr "您已更新 {memberName}。" @@ -14716,4 +14771,3 @@ msgstr "您的验证码:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/types/name.test.ts b/packages/lib/types/name.test.ts new file mode 100644 index 000000000..376831883 --- /dev/null +++ b/packages/lib/types/name.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { ZNameSchema } from './name'; + +describe('ZNameSchema', () => { + describe('valid names', () => { + it('accepts a normal name', () => { + expect(ZNameSchema.safeParse('Example User')).toEqual({ + success: true, + data: 'Example User', + }); + }); + + it('accepts international characters', () => { + expect(ZNameSchema.safeParse('Døcumensø Üser')).toEqual({ + success: true, + data: 'Døcumensø Üser', + }); + }); + + it('trims surrounding whitespace', () => { + expect(ZNameSchema.safeParse(' Documenso User ')).toEqual({ + success: true, + data: 'Documenso User', + }); + }); + + it('accepts names at the minimum length', () => { + expect(ZNameSchema.safeParse('DU')).toEqual({ + success: true, + data: 'DU', + }); + }); + + it('accepts names at the maximum length', () => { + const name = + 'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Do'; + + expect(name.length).toBe(100); + expect(ZNameSchema.safeParse(name)).toEqual({ + success: true, + data: name, + }); + }); + }); + + describe('length validation', () => { + it('rejects names shorter than 2 characters', () => { + expect(ZNameSchema.safeParse('D')).toMatchObject({ + success: false, + error: { + issues: [{ message: 'Please enter a valid name.' }], + }, + }); + }); + + it('rejects names longer than 100 characters', () => { + const name = + 'DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser DocumensoUser Doc'; + + expect(name.length).toBe(101); + expect(ZNameSchema.safeParse(name)).toMatchObject({ + success: false, + error: { + issues: [{ message: 'Name cannot be more than 100 characters.' }], + }, + }); + }); + + it('rejects whitespace-only input after trim', () => { + expect(ZNameSchema.safeParse(' ')).toMatchObject({ + success: false, + }); + }); + }); + + describe('URL validation', () => { + it.each([ + 'https://example.com', + 'http://example.com', + 'HTTPS://EXAMPLE.COM', + 'Northwind www.example.com', + 'www.example.com', + ])('rejects URLs in names: %s', (value) => { + expect(ZNameSchema.safeParse(value)).toMatchObject({ + success: false, + error: { + issues: expect.arrayContaining([expect.objectContaining({ message: 'Name cannot contain URLs.' })]), + }, + }); + }); + }); + + describe('invalid character validation', () => { + it.each([ + ['NUL character', 'Acme\u0000Corp'], + ['zero-width space', 'Acme\u200bCorp'], + ['bidi override', 'Acme\u202eCorp'], + ['byte order mark', 'Acme\ufeffCorp'], + ['lone surrogate', 'Acme\ud800Corp'], + ['tag character', `Acme${String.fromCodePoint(0xe0041)}Corp`], + ['noncharacter', 'Acme\ufffeCorp'], + ['private use character', 'Acme\ue000Corp'], + ['Hangul filler', 'Acme\u3164Corp'], + ['braille blank', 'Acme\u2800Corp'], + ['combining grapheme joiner', 'Acme\u034fCorp'], + ])('rejects names containing a %s', (_label, value) => { + expect(ZNameSchema.safeParse(value)).toMatchObject({ + success: false, + error: { + issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]), + }, + }); + }); + + it.each([ + ['fixed form', String.raw`Acme\u200bCorp`], + ['uppercase U', String.raw`Acme\U200BCorp`], + ['braced form', String.raw`Acme\u{200b}Corp`], + ['braced form with leading zeros', String.raw`Acme\u{0000200b}Corp`], + ['lone surrogate', String.raw`Acme\ud800Corp`], + ])('rejects literal \\u escape sequences stored as text (%s)', (_label, value) => { + expect(ZNameSchema.safeParse(value)).toMatchObject({ + success: false, + error: { + issues: expect.arrayContaining([expect.objectContaining({ message: 'Name contains invalid characters.' })]), + }, + }); + }); + + it.each([ + ['escape of a valid code point', String.raw`Acme\u0041Corp`], + ['braced escape of a valid astral code point', String.raw`Acme\u{1F600}Corp`], + ['braced escape beyond the Unicode range', String.raw`Acme\u{FFFFFFF}Corp`], + ['incomplete escape sequence', String.raw`Acme\u00 Corp`], + ['unterminated braced escape', String.raw`Acme\u{200bCorp`], + ['astral characters such as emoji', 'Acme 😀 Corp'], + ['emoji with a variation selector', 'I ❤️ Docs'], + ])('accepts %s', (_label, value) => { + expect(ZNameSchema.safeParse(value)).toMatchObject({ + success: true, + }); + }); + }); +}); diff --git a/packages/lib/types/name.ts b/packages/lib/types/name.ts new file mode 100644 index 000000000..14f0f4b7d --- /dev/null +++ b/packages/lib/types/name.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; + +export const URL_PATTERN = /https?:\/\/|www\./i; + +/** + * Characters that render as empty/invisible or break text layout: + * + * - `\p{C}` - control, format, lone surrogate, private use and + * unassigned code points (NUL, zero-width spaces, bidi + * overrides, BOM, tag characters, noncharacters). + * - `\p{Zl}\p{Zp}` - line and paragraph separators. + * - `\u{034F}` - combining grapheme joiner (invisible). Kept outside the + * character class because it is a combining mark, which + * lint rules reject inside classes. + * - remaining - letters that render as blank (Hangul fillers, braille blank). + * + * The `\p{...}` classes are maintained by the Unicode database, so newly + * assigned characters in these categories are covered automatically. + */ +const INVALID_CHARACTER_REGEX = /[\p{C}\p{Zl}\p{Zp}\u{115F}\u{1160}\u{2800}\u{3164}\u{FFA0}]|\u{034F}/u; + +const hasInvalidCharacter = (value: string) => INVALID_CHARACTER_REGEX.test(value); + +/** + * Matches literal `\uXXXX` and `\u{XXXX}` escape sequences stored verbatim as + * text (e.g. the 6 characters `\`, `u`, `2`, `0`, `0`, `b`), which can still + * break rendering downstream if anything decodes them. + */ +const ESCAPE_SEQUENCE_PATTERN = /\\u(?:([0-9a-f]{4})|\{([0-9a-f]+)\})/gi; + +const hasInvalidEscapeSequence = (value: string) => { + for (const [, fixedHex, bracedHex] of value.matchAll(ESCAPE_SEQUENCE_PATTERN)) { + const codePoint = parseInt(fixedHex ?? bracedHex, 16); + + if (codePoint > 0x10ffff) { + continue; + } + + // Decode the escape and run it through the same character policy as the + // unescaped check, so the two can never drift apart. + if (hasInvalidCharacter(String.fromCodePoint(codePoint))) { + return true; + } + } + + return false; +}; + +export const hasInvalidTextCharacters = (value: string) => + hasInvalidCharacter(value) || hasInvalidEscapeSequence(value); + +/** + * Shared name schema that disallows URLs to prevent phishing via email rendering, + * and invisible/control characters that render as empty or break the UI. + */ +export const ZNameSchema = z + .string() + .trim() + .min(2, { message: 'Please enter a valid name.' }) + .max(100, { message: 'Name cannot be more than 100 characters.' }) + .refine((value) => !URL_PATTERN.test(value), { + message: 'Name cannot contain URLs.', + }) + .refine((value) => !hasInvalidTextCharacters(value), { + message: 'Name contains invalid characters.', + }); + +export type TName = z.infer; diff --git a/packages/lib/types/subscription.ts b/packages/lib/types/subscription.ts index d25028e10..48daddbcb 100644 --- a/packages/lib/types/subscription.ts +++ b/packages/lib/types/subscription.ts @@ -6,14 +6,39 @@ import { z } from 'zod'; * * Example: "5m", "1h", "1d" */ -export const ZRateLimitWindowSchema = z.string().regex(/^\d+[smhd]$/); +export const RATE_LIMIT_WINDOW_REGEX = /^\d+[smhd]$/; -export const ZRateLimitArraySchema = z.array( - z.object({ - window: ZRateLimitWindowSchema, - max: z.number().int().positive(), - }), -); +const RATE_LIMIT_WINDOW_ERROR_MESSAGE = 'Use a duration with a unit, e.g. 5m, 1h, or 24h'; +const RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE = 'Use a unique window for each rate limit'; + +export const ZRateLimitWindowSchema = z.string().trim().regex(RATE_LIMIT_WINDOW_REGEX, { + message: RATE_LIMIT_WINDOW_ERROR_MESSAGE, +}); + +export const ZRateLimitArraySchema = z + .array( + z.object({ + window: ZRateLimitWindowSchema, + max: z.number().int().positive(), + }), + ) + .superRefine((entries, ctx) => { + const windows = new Set(); + + entries.forEach((entry, index) => { + const window = entry.window.trim(); + + if (windows.has(window)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: RATE_LIMIT_DUPLICATE_WINDOW_ERROR_MESSAGE, + path: [index, 'window'], + }); + } + + windows.add(window); + }); + }); export type TRateLimitArray = z.infer; @@ -52,7 +77,7 @@ export const ZClaimFlagsSchema = z.object({ signingReminders: z.boolean().optional(), cscQesSigning: z.boolean().optional(), - + /** * Controls whether an organisation is prevented from sending emails. * diff --git a/packages/lib/universal/quota-usage.test.ts b/packages/lib/universal/quota-usage.test.ts new file mode 100644 index 000000000..4a992bbdb --- /dev/null +++ b/packages/lib/universal/quota-usage.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { + getQuotaUsagePercent, + getQuotaWarningCount, + isQuotaExceeded, + isQuotaNearing, + normalizeCapacityLimit, +} from './quota-usage'; + +describe('isQuotaExceeded', () => { + it('treats null quota as unlimited (never exceeded)', () => { + expect(isQuotaExceeded(null, 0)).toBe(false); + expect(isQuotaExceeded(null, 1_000_000)).toBe(false); + }); + + it('treats a zero quota as blocked (always exceeded)', () => { + expect(isQuotaExceeded(0, 0)).toBe(true); + expect(isQuotaExceeded(0, 5)).toBe(true); + }); + + it('is exceeded once usage reaches the quota (>= boundary)', () => { + expect(isQuotaExceeded(10, 9)).toBe(false); + expect(isQuotaExceeded(10, 10)).toBe(true); + expect(isQuotaExceeded(10, 11)).toBe(true); + }); +}); + +describe('getQuotaWarningCount', () => { + it('rounds the 80% threshold up', () => { + expect(getQuotaWarningCount(10)).toBe(8); + expect(getQuotaWarningCount(100)).toBe(80); + // 5 * 0.8 = 4 exactly. + expect(getQuotaWarningCount(5)).toBe(4); + // 3 * 0.8 = 2.4 -> 3, so the warning count equals the quota itself. + expect(getQuotaWarningCount(3)).toBe(3); + }); +}); + +describe('isQuotaNearing', () => { + it('is never nearing for unlimited or blocked quotas', () => { + expect(isQuotaNearing(null, 5)).toBe(false); + expect(isQuotaNearing(0, 5)).toBe(false); + }); + + it('is nearing from the warning threshold up to (but not including) the quota', () => { + expect(isQuotaNearing(10, 7)).toBe(false); + expect(isQuotaNearing(10, 8)).toBe(true); + expect(isQuotaNearing(10, 9)).toBe(true); + }); + + it('is not nearing once exceeded (nearing and exceeded are mutually exclusive)', () => { + expect(isQuotaNearing(10, 10)).toBe(false); + expect(isQuotaNearing(10, 11)).toBe(false); + }); + + it('can never fire for tiny quotas where the warning count equals the quota', () => { + // getQuotaWarningCount(3) === 3, so usage >= 3 is already exceeded. + expect(isQuotaNearing(3, 2)).toBe(false); + expect(isQuotaNearing(3, 3)).toBe(false); + }); + + it('agrees with the warning-count helper at the boundary', () => { + const quota = 250; + const warningCount = getQuotaWarningCount(quota); + + expect(isQuotaNearing(quota, warningCount - 1)).toBe(false); + expect(isQuotaNearing(quota, warningCount)).toBe(true); + }); +}); + +describe('getQuotaUsagePercent', () => { + it('returns 0 for unlimited or non-positive quotas', () => { + expect(getQuotaUsagePercent(5, null)).toBe(0); + expect(getQuotaUsagePercent(5, 0)).toBe(0); + expect(getQuotaUsagePercent(5, -10)).toBe(0); + }); + + it('rounds the percentage to the nearest integer', () => { + expect(getQuotaUsagePercent(1, 3)).toBe(33); + expect(getQuotaUsagePercent(2, 3)).toBe(67); + expect(getQuotaUsagePercent(50, 100)).toBe(50); + }); + + it('clamps the percentage to 100 when usage exceeds the quota', () => { + expect(getQuotaUsagePercent(150, 100)).toBe(100); + }); +}); + +describe('normalizeCapacityLimit', () => { + it('maps 0 (unlimited for capacity limits) to null', () => { + expect(normalizeCapacityLimit(0)).toBeNull(); + }); + + it('passes positive limits through unchanged', () => { + expect(normalizeCapacityLimit(1)).toBe(1); + expect(normalizeCapacityLimit(25)).toBe(25); + }); +}); diff --git a/packages/lib/universal/quota-usage.ts b/packages/lib/universal/quota-usage.ts new file mode 100644 index 000000000..fc2c23449 --- /dev/null +++ b/packages/lib/universal/quota-usage.ts @@ -0,0 +1,57 @@ +export const QUOTA_WARNING_THRESHOLD = 0.8; + +/** + * Monthly quotas: `null` = unlimited, `0` = blocked. Usage `>=` quota is exceeded. + */ +export const isQuotaExceeded = (quota: number | null, usage: number): boolean => { + if (quota === null) { + return false; + } + + if (quota === 0) { + return true; + } + + return usage >= quota; +}; + +/** + * The usage count at which a positive quota starts "nearing" (80% rounded up). + * The single source for the warning threshold math so the UI panel, quota flags, + * and the per-request alert path can't drift apart. + */ +export const getQuotaWarningCount = (quota: number): number => { + return Math.ceil(quota * QUOTA_WARNING_THRESHOLD); +}; + +/** + * Nearing once usage reaches the warning threshold (80% rounded up) but is not exceeded. + */ +export const isQuotaNearing = (quota: number | null, usage: number): boolean => { + if (quota === null || quota === 0) { + return false; + } + + if (isQuotaExceeded(quota, usage)) { + return false; + } + + return usage >= getQuotaWarningCount(quota); +}; + +export const getQuotaUsagePercent = (usage: number, quota: number | null): number => { + if (quota === null || quota <= 0) { + return 0; + } + + return Math.min(100, Math.round((usage / quota) * 100)); +}; + +/** Member/team capacity limits use `0` for unlimited. */ +export const normalizeCapacityLimit = (limit: number): number | null => { + if (limit === 0) { + return null; + } + + return limit; +}; diff --git a/packages/lib/universal/upload/put-file.ts b/packages/lib/universal/upload/put-file.ts index 5a7eeecc0..46b53a861 100644 --- a/packages/lib/universal/upload/put-file.ts +++ b/packages/lib/universal/upload/put-file.ts @@ -1,10 +1,5 @@ -import { env } from '@documenso/lib/utils/env'; -import type { TGetPresignedPostUrlResponse, TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types'; -import { DocumentDataType } from '@prisma/client'; -import { base64 } from '@scure/base'; -import { match } from 'ts-pattern'; +import type { TUploadPdfResponse } from '@documenso/remix/server/api/files/files.types'; -import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { AppError } from '../../errors/app-error'; type File = { @@ -58,68 +53,3 @@ export const putPdfFile = async (file: File, options?: PutFileOptions) => { return result; }; - -/** - * Uploads a file to the appropriate storage location. - */ -export const putFile = async (file: File, options?: PutFileOptions) => { - const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT'); - - return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT) - .with('s3', async () => putFileInObjectStorage(file, {}, options)) - .with('azure-blob', async () => putFileInObjectStorage(file, { 'x-ms-blob-type': 'BlockBlob' }, options)) - .otherwise(async () => putFileInDatabase(file)); -}; - -const putFileInDatabase = async (file: File) => { - const contents = await file.arrayBuffer(); - - const binaryData = new Uint8Array(contents); - - const asciiData = base64.encode(binaryData); - - return { - type: DocumentDataType.BYTES_64, - data: asciiData, - }; -}; - -const putFileInObjectStorage = async (file: File, extraHeaders: Record, options?: PutFileOptions) => { - const getPresignedUrlResponse = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/files/presigned-post-url`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...buildUploadAuthHeaders(options), - }, - body: JSON.stringify({ - fileName: file.name, - contentType: file.type, - }), - }); - - if (!getPresignedUrlResponse.ok) { - throw new Error(`Failed to get presigned post url, failed with status code ${getPresignedUrlResponse.status}`); - } - - const { url, key }: TGetPresignedPostUrlResponse = await getPresignedUrlResponse.json(); - - const body = await file.arrayBuffer(); - - const response = await fetch(url, { - method: 'PUT', - headers: { - 'Content-Type': 'application/octet-stream', - ...extraHeaders, - }, - body, - }); - - if (!response.ok) { - throw new Error(`Failed to upload file "${file.name}", failed with status code ${response.status}`); - } - - return { - type: DocumentDataType.S3_PATH, - data: key, - }; -}; diff --git a/packages/lib/utils/images/logo.ts b/packages/lib/utils/images/logo.ts index b81f7c012..98b2edebb 100644 --- a/packages/lib/utils/images/logo.ts +++ b/packages/lib/utils/images/logo.ts @@ -8,3 +8,15 @@ export const loadLogo = async (file: Uint8Array) => { content, }; }; + +/** + * Validate and sanitise an uploaded branding logo. Re-encoding through `sharp` + * proves the bytes are a real raster image and strips any embedded payloads. + * Throws if the input cannot be parsed as an image. + */ +export const optimiseBrandingLogo = async (input: Buffer | Uint8Array): Promise => { + return await sharp(input) + .resize(512, 512, { fit: 'inside', withoutEnlargement: true }) + .png({ quality: 80 }) + .toBuffer(); +}; diff --git a/packages/lib/utils/is-http-url.test.ts b/packages/lib/utils/is-http-url.test.ts new file mode 100644 index 000000000..3454f32eb --- /dev/null +++ b/packages/lib/utils/is-http-url.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { isHttpUrl, toSafeHref } from './is-http-url'; + +describe('isHttpUrl', () => { + it('accepts http and https URLs', () => { + expect(isHttpUrl('http://example.com')).toBe(true); + expect(isHttpUrl('https://example.com/path?q=1#hash')).toBe(true); + expect(isHttpUrl('HTTPS://EXAMPLE.COM')).toBe(true); + }); + + it('rejects non-http(s) schemes', () => { + expect(isHttpUrl('javascript:alert(1)')).toBe(false); + expect(isHttpUrl('JavaScript:alert(1)')).toBe(false); + expect(isHttpUrl('data:text/html,')).toBe(false); + expect(isHttpUrl('vbscript:msgbox(1)')).toBe(false); + expect(isHttpUrl('file:///etc/passwd')).toBe(false); + }); + + it('rejects non-absolute or unparseable values', () => { + expect(isHttpUrl('not a url')).toBe(false); + expect(isHttpUrl('/relative/path')).toBe(false); + expect(isHttpUrl('')).toBe(false); + }); + + it('does not treat leading whitespace tricks as safe', () => { + // `new URL` trims leading control chars; ensure a smuggled scheme is rejected. + expect(isHttpUrl(' javascript:alert(1)')).toBe(false); + expect(isHttpUrl('java\tscript:alert(1)')).toBe(false); + }); +}); + +describe('toSafeHref', () => { + it('returns the URL when it is http(s)', () => { + expect(toSafeHref('https://example.com')).toBe('https://example.com'); + }); + + it('returns undefined for dangerous or empty values', () => { + expect(toSafeHref('javascript:alert(1)')).toBeUndefined(); + expect(toSafeHref('data:text/html,x')).toBeUndefined(); + expect(toSafeHref('')).toBeUndefined(); + expect(toSafeHref(null)).toBeUndefined(); + expect(toSafeHref(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/lib/utils/is-http-url.ts b/packages/lib/utils/is-http-url.ts new file mode 100644 index 000000000..e5727349b --- /dev/null +++ b/packages/lib/utils/is-http-url.ts @@ -0,0 +1,32 @@ +const ALLOWED_PROTOCOLS = ['http', 'https']; + +/** + * Returns true only when `value` parses as an absolute URL using the http or + * https protocol. + * + * Zod's `.url()` accepts any parseable URL, including non-web schemes. Use this + * to restrict user-supplied URLs to http(s) before they are stored or rendered + * as a link. + */ +export const isHttpUrl = (value: string) => { + try { + const url = new URL(value); + + return ALLOWED_PROTOCOLS.includes(url.protocol.slice(0, -1).toLowerCase()); + } catch { + return false; + } +}; + +/** + * Returns the value to use for a link `href` only when it is an http(s) URL, + * otherwise `undefined`. Use this when rendering user-supplied URLs as anchors, + * including for older rows stored before URL validation was in place. + */ +export const toSafeHref = (value: string | null | undefined): string | undefined => { + if (!value || !isHttpUrl(value)) { + return undefined; + } + + return value; +}; diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 6b4f4f31a..67f2ae110 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -35,7 +35,7 @@ "devDependencies": { "dotenv": "^17.2.3", "dotenv-cli": "^11.0.0", - "tsx": "^4.20.6", + "tsx": "^4.23.1", "typescript": "5.6.2" } } diff --git a/packages/prisma/seed/initial-seed.ts b/packages/prisma/seed/initial-seed.ts index 2833b3350..ce5065672 100644 --- a/packages/prisma/seed/initial-seed.ts +++ b/packages/prisma/seed/initial-seed.ts @@ -361,9 +361,9 @@ export const seedAlignmentTestDocument = async ({ const { id, recipients, envelopeItems } = createdEnvelope; if (isDirectTemplate) { - const directTemplateRecpient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL); + const directTemplateRecipient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL); - if (!directTemplateRecpient) { + if (!directTemplateRecipient) { throw new Error('Need to create a direct template recipient'); } @@ -372,7 +372,7 @@ export const seedAlignmentTestDocument = async ({ envelopeId: id, enabled: true, token: directTemplateToken ?? Math.random().toString(), - directTemplateRecipientId: directTemplateRecpient.id, + directTemplateRecipientId: directTemplateRecipient.id, }, }); } diff --git a/packages/prisma/seed/templates.ts b/packages/prisma/seed/templates.ts index b72e0a39c..86f761954 100644 --- a/packages/prisma/seed/templates.ts +++ b/packages/prisma/seed/templates.ts @@ -6,6 +6,7 @@ import { DIRECT_TEMPLATE_RECIPIENT_NAME, } from '@documenso/lib/constants/direct-templates'; import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id'; +import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; import { SignatureLevel } from '@documenso/lib/types/signature-level'; import { prefixedId } from '@documenso/lib/universal/id'; @@ -15,6 +16,7 @@ import { DocumentDataType, DocumentSource, EnvelopeType, + FieldType, ReadStatus, RecipientRole, SendStatus, @@ -29,6 +31,11 @@ type SeedTemplateOptions = { teamId: number; internalVersion?: 1 | 2; createTemplateOptions?: Partial; + /** + * Only used by seedDirectTemplate. Creates a signature field for the direct + * recipient so the seeded direct template is valid. Defaults to true. + */ + createDirectRecipientSignatureField?: boolean; }; type CreateTemplateOptions = { @@ -198,11 +205,11 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => { }, }); - const directTemplateRecpient = template.recipients.find( + const directTemplateRecipient = template.recipients.find( (recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL, ); - if (!directTemplateRecpient) { + if (!directTemplateRecipient) { throw new Error('Need to create a direct template recipient'); } @@ -211,10 +218,35 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => { envelopeId: template.id, enabled: true, token: Math.random().toString(), - directTemplateRecipientId: directTemplateRecpient.id, + directTemplateRecipientId: directTemplateRecipient.id, }, }); + const { createDirectRecipientSignatureField = true } = options; + + if (createDirectRecipientSignatureField) { + const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({ + where: { envelopeId: template.id }, + }); + + await prisma.field.create({ + data: { + envelopeId: template.id, + envelopeItemId: envelopeItem.id, + recipientId: directTemplateRecipient.id, + type: FieldType.SIGNATURE, + page: 1, + positionX: 5, + positionY: 10, + width: 20, + height: 5, + customText: '', + inserted: false, + fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES, + }, + }); + } + return await prisma.envelope.findFirstOrThrow({ where: { id: template.id, diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json index c405307c8..3820e782a 100644 --- a/packages/tailwind-config/package.json +++ b/packages/tailwind-config/package.json @@ -11,7 +11,7 @@ "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/typography": "^0.5.19", "autoprefixer": "^10.4.22", - "postcss": "^8.5.14", + "postcss": "^8.5.19", "tailwindcss": "^3.4.18", "tailwindcss-animate": "^1.0.7" }, diff --git a/packages/trpc/server/admin-router/create-admin-organisation.types.ts b/packages/trpc/server/admin-router/create-admin-organisation.types.ts index 7c142bba6..a86750ab3 100644 --- a/packages/trpc/server/admin-router/create-admin-organisation.types.ts +++ b/packages/trpc/server/admin-router/create-admin-organisation.types.ts @@ -1,11 +1,10 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { z } from 'zod'; -import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types'; - export const ZCreateAdminOrganisationRequestSchema = z.object({ ownerUserId: z.number(), data: z.object({ - name: ZOrganisationNameSchema, + name: ZNameSchema, }), }); diff --git a/packages/trpc/server/admin-router/create-subscription-claim.types.ts b/packages/trpc/server/admin-router/create-subscription-claim.types.ts index f1cfbad60..9cb1da986 100644 --- a/packages/trpc/server/admin-router/create-subscription-claim.types.ts +++ b/packages/trpc/server/admin-router/create-subscription-claim.types.ts @@ -1,8 +1,9 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { ZClaimFlagsSchema, ZRateLimitArraySchema } from '@documenso/lib/types/subscription'; import { z } from 'zod'; export const ZCreateSubscriptionClaimRequestSchema = z.object({ - name: z.string().min(1), + name: ZNameSchema, teamCount: z.number().int().min(0), memberCount: z.number().int().min(0), envelopeItemCount: z.number().int().min(1), diff --git a/packages/trpc/server/admin-router/create-user.types.ts b/packages/trpc/server/admin-router/create-user.types.ts index 6e1b65438..6c629a291 100644 --- a/packages/trpc/server/admin-router/create-user.types.ts +++ b/packages/trpc/server/admin-router/create-user.types.ts @@ -1,4 +1,4 @@ -import { ZNameSchema } from '@documenso/lib/constants/auth'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { z } from 'zod'; export const ZCreateUserRequestSchema = z.object({ diff --git a/packages/trpc/server/admin-router/email-transport/create-email-transport.types.ts b/packages/trpc/server/admin-router/email-transport/create-email-transport.types.ts index 26a47c12c..ecfa77bd4 100644 --- a/packages/trpc/server/admin-router/email-transport/create-email-transport.types.ts +++ b/packages/trpc/server/admin-router/email-transport/create-email-transport.types.ts @@ -1,9 +1,10 @@ import { ZEmailTransportConfigSchema } from '@documenso/lib/server-only/email/email-transport-config'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { z } from 'zod'; export const ZCreateEmailTransportRequestSchema = z.object({ - name: z.string().min(1), - fromName: z.string().min(1), + name: ZNameSchema, + fromName: ZNameSchema, fromAddress: z.string().email(), config: ZEmailTransportConfigSchema, }); diff --git a/packages/trpc/server/admin-router/email-transport/update-email-transport.types.ts b/packages/trpc/server/admin-router/email-transport/update-email-transport.types.ts index d007c7e19..c8924c175 100644 --- a/packages/trpc/server/admin-router/email-transport/update-email-transport.types.ts +++ b/packages/trpc/server/admin-router/email-transport/update-email-transport.types.ts @@ -4,6 +4,7 @@ import { ZSmtpApiConfigSchema, ZSmtpAuthConfigSchema, } from '@documenso/lib/server-only/email/email-transport-config'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { z } from 'zod'; // Reuses the canonical transport config schemas, but relaxes the secret field so @@ -21,8 +22,8 @@ const ZUpdateConfigSchema = z.discriminatedUnion('type', [ export const ZUpdateEmailTransportRequestSchema = z.object({ id: z.string(), data: z.object({ - name: z.string().min(1), - fromName: z.string().min(1), + name: ZNameSchema, + fromName: ZNameSchema, fromAddress: z.string().email(), config: ZUpdateConfigSchema, }), diff --git a/packages/trpc/server/admin-router/get-admin-team.ts b/packages/trpc/server/admin-router/get-admin-team.ts index f56deb526..2213c2caf 100644 --- a/packages/trpc/server/admin-router/get-admin-team.ts +++ b/packages/trpc/server/admin-router/get-admin-team.ts @@ -30,6 +30,7 @@ export const getAdminTeamRoute = adminProcedure name: true, url: true, ownerUserId: true, + organisationGlobalSettings: true, }, }, teamEmail: true, diff --git a/packages/trpc/server/admin-router/get-admin-team.types.ts b/packages/trpc/server/admin-router/get-admin-team.types.ts index 14952b760..013bebdd5 100644 --- a/packages/trpc/server/admin-router/get-admin-team.types.ts +++ b/packages/trpc/server/admin-router/get-admin-team.types.ts @@ -1,5 +1,6 @@ import { OrganisationMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema'; import { TeamMemberRoleSchema } from '@documenso/prisma/generated/zod/inputTypeSchemas/TeamMemberRoleSchema'; +import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema'; import OrganisationMemberInviteSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema'; import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema'; import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema'; @@ -19,6 +20,8 @@ export const ZGetAdminTeamResponseSchema = TeamSchema.extend({ name: true, url: true, ownerUserId: true, + }).extend({ + organisationGlobalSettings: OrganisationGlobalSettingsSchema, }), teamEmail: TeamEmailSchema.nullable(), teamGlobalSettings: TeamGlobalSettingsSchema.nullable(), diff --git a/packages/trpc/server/admin-router/update-admin-organisation.types.ts b/packages/trpc/server/admin-router/update-admin-organisation.types.ts index 5f5d4a17a..47e9927cc 100644 --- a/packages/trpc/server/admin-router/update-admin-organisation.types.ts +++ b/packages/trpc/server/admin-router/update-admin-organisation.types.ts @@ -1,13 +1,13 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { z } from 'zod'; -import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types'; import { ZTeamUrlSchema } from '../team-router/schema'; import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types'; export const ZUpdateAdminOrganisationRequestSchema = z.object({ organisationId: z.string(), data: z.object({ - name: ZOrganisationNameSchema.optional(), + name: ZNameSchema.optional(), url: ZTeamUrlSchema.optional(), claims: ZCreateSubscriptionClaimRequestSchema.pick({ teamCount: true, diff --git a/packages/trpc/server/admin-router/update-user.types.ts b/packages/trpc/server/admin-router/update-user.types.ts index 153b5a785..300db6fa7 100644 --- a/packages/trpc/server/admin-router/update-user.types.ts +++ b/packages/trpc/server/admin-router/update-user.types.ts @@ -1,10 +1,11 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { zEmail } from '@documenso/lib/utils/zod'; import { Role } from '@prisma/client'; import { z } from 'zod'; export const ZUpdateUserRequestSchema = z.object({ id: z.number().min(1), - name: z.string().nullish(), + name: ZNameSchema.nullish(), email: zEmail().optional(), roles: z.array(z.nativeEnum(Role)).optional(), }); diff --git a/packages/trpc/server/api-token-router/create-api-token.types.ts b/packages/trpc/server/api-token-router/create-api-token.types.ts index c73c65833..c362bdf50 100644 --- a/packages/trpc/server/api-token-router/create-api-token.types.ts +++ b/packages/trpc/server/api-token-router/create-api-token.types.ts @@ -1,8 +1,9 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { z } from 'zod'; export const ZCreateApiTokenRequestSchema = z.object({ teamId: z.number(), - tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }), + tokenName: ZNameSchema, expirationDate: z.string().nullable(), }); diff --git a/packages/trpc/server/auth-router/create-passkey.types.ts b/packages/trpc/server/auth-router/create-passkey.types.ts index d5d2483a4..b6ff4a163 100644 --- a/packages/trpc/server/auth-router/create-passkey.types.ts +++ b/packages/trpc/server/auth-router/create-passkey.types.ts @@ -1,8 +1,9 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { ZRegistrationResponseJSONSchema } from '@documenso/lib/types/webauthn'; import { z } from 'zod'; export const ZCreatePasskeyRequestSchema = z.object({ - passkeyName: z.string().trim().min(1), + passkeyName: ZNameSchema, verificationResponse: ZRegistrationResponseJSONSchema, }); diff --git a/packages/trpc/server/auth-router/update-passkey.types.ts b/packages/trpc/server/auth-router/update-passkey.types.ts index e898234da..310389396 100644 --- a/packages/trpc/server/auth-router/update-passkey.types.ts +++ b/packages/trpc/server/auth-router/update-passkey.types.ts @@ -1,8 +1,9 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { z } from 'zod'; export const ZUpdatePasskeyRequestSchema = z.object({ passkeyId: z.string().trim().min(1), - name: z.string().trim().min(1), + name: ZNameSchema, }); export const ZUpdatePasskeyResponseSchema = z.void(); diff --git a/packages/trpc/server/document-router/download-document-audit-logs.ts b/packages/trpc/server/document-router/download-document-audit-logs.ts index a2dfc0731..398fbfb6c 100644 --- a/packages/trpc/server/document-router/download-document-audit-logs.ts +++ b/packages/trpc/server/document-router/download-document-audit-logs.ts @@ -15,18 +15,18 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure .output(ZDownloadDocumentAuditLogsResponseSchema) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; - const { documentId } = input; + const { envelopeId } = input; ctx.logger.info({ input: { - documentId, + envelopeId, }, }); const envelope = await getEnvelopeById({ id: { - type: 'documentId', - id: documentId, + type: 'envelopeId', + id: envelopeId, }, type: EnvelopeType.DOCUMENT, userId: ctx.user.id, @@ -55,10 +55,8 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure const result = await certificatePdf.save(); - const base64 = Buffer.from(result).toString('base64'); - return { - data: base64, + data: Buffer.from(result).toString('base64'), envelopeTitle: envelope.title, }; }); diff --git a/packages/trpc/server/document-router/download-document-audit-logs.types.ts b/packages/trpc/server/document-router/download-document-audit-logs.types.ts index a06825f2e..e5766b1ec 100644 --- a/packages/trpc/server/document-router/download-document-audit-logs.types.ts +++ b/packages/trpc/server/document-router/download-document-audit-logs.types.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const ZDownloadDocumentAuditLogsRequestSchema = z.object({ - documentId: z.number(), + envelopeId: z.string(), }); export const ZDownloadDocumentAuditLogsResponseSchema = z.object({ diff --git a/packages/trpc/server/document-router/download-document-certificate.ts b/packages/trpc/server/document-router/download-document-certificate.ts index 749afe50e..5180af962 100644 --- a/packages/trpc/server/document-router/download-document-certificate.ts +++ b/packages/trpc/server/document-router/download-document-certificate.ts @@ -17,18 +17,18 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure .output(ZDownloadDocumentCertificateResponseSchema) .mutation(async ({ input, ctx }) => { const { teamId } = ctx; - const { documentId } = input; + const { envelopeId } = input; ctx.logger.info({ input: { - documentId, + envelopeId, }, }); const { envelopeWhereInput } = await getEnvelopeWhereInput({ id: { - type: 'documentId', - id: documentId, + type: 'envelopeId', + id: envelopeId, }, type: EnvelopeType.DOCUMENT, userId: ctx.user.id, @@ -81,10 +81,8 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure const result = await certificatePdf.save(); - const base64 = Buffer.from(result).toString('base64'); - return { - data: base64, + data: Buffer.from(result).toString('base64'), envelopeTitle: envelope.title, }; }); diff --git a/packages/trpc/server/document-router/download-document-certificate.types.ts b/packages/trpc/server/document-router/download-document-certificate.types.ts index ca8107cc8..72bdd3e6a 100644 --- a/packages/trpc/server/document-router/download-document-certificate.types.ts +++ b/packages/trpc/server/document-router/download-document-certificate.types.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const ZDownloadDocumentCertificateRequestSchema = z.object({ - documentId: z.number(), + envelopeId: z.string(), }); export const ZDownloadDocumentCertificateResponseSchema = z.object({ diff --git a/packages/trpc/server/enterprise-router/create-organisation-email.types.ts b/packages/trpc/server/enterprise-router/create-organisation-email.types.ts index 22ff2779c..0a3830c53 100644 --- a/packages/trpc/server/enterprise-router/create-organisation-email.types.ts +++ b/packages/trpc/server/enterprise-router/create-organisation-email.types.ts @@ -1,9 +1,10 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { zEmail } from '@documenso/lib/utils/zod'; import { z } from 'zod'; export const ZCreateOrganisationEmailRequestSchema = z.object({ emailDomainId: z.string(), - emailName: z.string().min(1).max(100), + emailName: ZNameSchema, email: zEmail().toLowerCase(), // This does not need to be validated to be part of the domain. diff --git a/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts b/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts index f07be361d..5a8484381 100644 --- a/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts +++ b/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts @@ -1,3 +1,4 @@ +import { isHttpUrl } from '@documenso/lib/utils/is-http-url'; import { z } from 'zod'; import type { TrpcRouteMeta } from '../../trpc'; @@ -16,7 +17,7 @@ export const ZCreateAttachmentRequestSchema = z.object({ envelopeId: z.string(), data: z.object({ label: z.string().min(1, 'Label is required'), - data: z.string().url('Must be a valid URL'), + data: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'), }), }); diff --git a/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts b/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts index 2acff8257..2f60e1fc2 100644 --- a/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts +++ b/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts @@ -1,3 +1,4 @@ +import { isHttpUrl } from '@documenso/lib/utils/is-http-url'; import { z } from 'zod'; import { ZSuccessResponseSchema } from '../../schema'; @@ -17,7 +18,7 @@ export const ZUpdateAttachmentRequestSchema = z.object({ id: z.string(), data: z.object({ label: z.string().min(1, 'Label is required'), - data: z.string().url('Must be a valid URL'), + data: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'), }), }); diff --git a/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.ts b/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.ts new file mode 100644 index 000000000..3bd8fc74a --- /dev/null +++ b/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.ts @@ -0,0 +1,23 @@ +import { authenticatedProcedure } from '../trpc'; +import { + downloadEnvelopeAuditLogPdfMeta, + ZDownloadEnvelopeAuditLogPdfRequestSchema, + ZDownloadEnvelopeAuditLogPdfResponseSchema, +} from './download-envelope-audit-log-pdf.types'; + +export const downloadEnvelopeAuditLogPdfRoute = authenticatedProcedure + .meta(downloadEnvelopeAuditLogPdfMeta) + .input(ZDownloadEnvelopeAuditLogPdfRequestSchema) + .output(ZDownloadEnvelopeAuditLogPdfResponseSchema) + .query(({ input, ctx }) => { + const { envelopeId } = input; + + ctx.logger.info({ + input: { + envelopeId, + }, + }); + + // This endpoint is purely for V2 API, which is implemented in the Hono remix server. + throw new Error('NOT_IMPLEMENTED'); + }); diff --git a/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.types.ts b/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.types.ts new file mode 100644 index 000000000..2c725d7f8 --- /dev/null +++ b/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.types.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import type { TrpcRouteMeta } from '../trpc'; + +export const downloadEnvelopeAuditLogPdfMeta: TrpcRouteMeta = { + openapi: { + method: 'GET', + path: '/envelope/{envelopeId}/audit-log/download', + summary: 'Download envelope audit log PDF', + description: 'Download the audit log for a document as a PDF.', + tags: ['Envelope'], + responseHeaders: z.object({ + 'Content-Type': z.literal('application/pdf'), + }), + }, +}; + +export const ZDownloadEnvelopeAuditLogPdfRequestSchema = z.object({ + envelopeId: z.string().describe('The ID of the envelope to download the audit log for.'), +}); + +export const ZDownloadEnvelopeAuditLogPdfResponseSchema = z.instanceof(Uint8Array); + +export type TDownloadEnvelopeAuditLogPdfRequest = z.infer; +export type TDownloadEnvelopeAuditLogPdfResponse = z.infer; diff --git a/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.ts b/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.ts new file mode 100644 index 000000000..9fe430bb4 --- /dev/null +++ b/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.ts @@ -0,0 +1,23 @@ +import { authenticatedProcedure } from '../trpc'; +import { + downloadEnvelopeCertificatePdfMeta, + ZDownloadEnvelopeCertificatePdfRequestSchema, + ZDownloadEnvelopeCertificatePdfResponseSchema, +} from './download-envelope-certificate-pdf.types'; + +export const downloadEnvelopeCertificatePdfRoute = authenticatedProcedure + .meta(downloadEnvelopeCertificatePdfMeta) + .input(ZDownloadEnvelopeCertificatePdfRequestSchema) + .output(ZDownloadEnvelopeCertificatePdfResponseSchema) + .query(({ input, ctx }) => { + const { envelopeId } = input; + + ctx.logger.info({ + input: { + envelopeId, + }, + }); + + // This endpoint is purely for V2 API, which is implemented in the Hono remix server. + throw new Error('NOT_IMPLEMENTED'); + }); diff --git a/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.types.ts b/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.types.ts new file mode 100644 index 000000000..b35e7aa11 --- /dev/null +++ b/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.types.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import type { TrpcRouteMeta } from '../trpc'; + +export const downloadEnvelopeCertificatePdfMeta: TrpcRouteMeta = { + openapi: { + method: 'GET', + path: '/envelope/{envelopeId}/certificate/download', + summary: 'Download envelope certificate PDF', + description: 'Download the signing certificate for a completed document as a PDF.', + tags: ['Envelope'], + responseHeaders: z.object({ + 'Content-Type': z.literal('application/pdf'), + }), + }, +}; + +export const ZDownloadEnvelopeCertificatePdfRequestSchema = z.object({ + envelopeId: z.string().describe('The ID of the envelope to download the certificate for.'), +}); + +export const ZDownloadEnvelopeCertificatePdfResponseSchema = z.instanceof(Uint8Array); + +export type TDownloadEnvelopeCertificatePdfRequest = z.infer; +export type TDownloadEnvelopeCertificatePdfResponse = z.infer; diff --git a/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts b/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts index 7ef3e8bc6..4372b9291 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts @@ -1,4 +1,5 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; @@ -41,5 +42,26 @@ export const getEnvelopeRecipientRoute = authenticatedProcedure }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'envelopeId', + id: recipient.envelopeId, + }, + type: null, + userId: user.id, + teamId, + }); + + // Additional validation to check visibility. + const envelope = await prisma.envelope.findUnique({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Recipient not found', + }); + } + return recipient; }); diff --git a/packages/trpc/server/envelope-router/router.ts b/packages/trpc/server/envelope-router/router.ts index 1dd54e436..c6e34dab1 100644 --- a/packages/trpc/server/envelope-router/router.ts +++ b/packages/trpc/server/envelope-router/router.ts @@ -12,6 +12,8 @@ import { createEnvelopeItemsRoute } from './create-envelope-items'; import { deleteEnvelopeRoute } from './delete-envelope'; import { deleteEnvelopeItemRoute } from './delete-envelope-item'; import { distributeEnvelopeRoute } from './distribute-envelope'; +import { downloadEnvelopeAuditLogPdfRoute } from './download-envelope-audit-log-pdf'; +import { downloadEnvelopeCertificatePdfRoute } from './download-envelope-certificate-pdf'; import { downloadEnvelopeItemRoute } from './download-envelope-item'; import { duplicateEnvelopeRoute } from './duplicate-envelope'; import { createEnvelopeFieldsRoute } from './envelope-fields/create-envelope-fields'; @@ -85,6 +87,10 @@ export const envelopeRouter = router({ find: findEnvelopesRoute, auditLog: { find: findEnvelopeAuditLogsRoute, + downloadPdf: downloadEnvelopeAuditLogPdfRoute, + }, + certificate: { + downloadPdf: downloadEnvelopeCertificatePdfRoute, }, bulk: { move: bulkMoveEnvelopesRoute, diff --git a/packages/trpc/server/folder-router/schema.ts b/packages/trpc/server/folder-router/schema.ts index a3c18b78d..396e121c1 100644 --- a/packages/trpc/server/folder-router/schema.ts +++ b/packages/trpc/server/folder-router/schema.ts @@ -1,4 +1,5 @@ import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; import { DocumentVisibility } from '@documenso/prisma/generated/types'; import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema'; @@ -42,7 +43,7 @@ const ZFolderParentIdSchema = z .describe('The folder ID to place this folder within. Leave empty to place folder at the root level.'); export const ZCreateFolderRequestSchema = z.object({ - name: z.string(), + name: ZNameSchema, parentId: ZFolderParentIdSchema.optional(), type: ZFolderTypeSchema.optional(), }); @@ -52,7 +53,7 @@ export const ZCreateFolderResponseSchema = ZFolderSchema; export const ZUpdateFolderRequestSchema = z.object({ folderId: z.string().describe('The ID of the folder to update'), data: z.object({ - name: z.string().optional().describe('The name of the folder'), + name: ZNameSchema.optional().describe('The name of the folder'), parentId: ZFolderParentIdSchema.optional().nullable(), visibility: z.nativeEnum(DocumentVisibility).optional().describe('The visibility of the folder'), pinned: z.boolean().optional().describe('Whether the folder should be pinned'), diff --git a/packages/trpc/server/organisation-router/create-organisation-group.types.ts b/packages/trpc/server/organisation-router/create-organisation-group.types.ts index af824d426..3588f8015 100644 --- a/packages/trpc/server/organisation-router/create-organisation-group.types.ts +++ b/packages/trpc/server/organisation-router/create-organisation-group.types.ts @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { OrganisationMemberRole } from '@prisma/client'; import { z } from 'zod'; @@ -14,7 +15,7 @@ import { z } from 'zod'; export const ZCreateOrganisationGroupRequestSchema = z.object({ organisationId: z.string(), organisationRole: z.nativeEnum(OrganisationMemberRole), - name: z.string().max(100), + name: ZNameSchema, memberIds: z.array(z.string()), }); diff --git a/packages/trpc/server/organisation-router/create-organisation.types.ts b/packages/trpc/server/organisation-router/create-organisation.types.ts index e18846120..97bb6ee86 100644 --- a/packages/trpc/server/organisation-router/create-organisation.types.ts +++ b/packages/trpc/server/organisation-router/create-organisation.types.ts @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { z } from 'zod'; // export const createOrganisationMeta: TrpcOpenApiMeta = { @@ -10,13 +11,8 @@ import { z } from 'zod'; // }, // }; -export const ZOrganisationNameSchema = z - .string() - .min(3, { message: 'Minimum 3 characters' }) - .max(50, { message: 'Maximum 50 characters' }); - export const ZCreateOrganisationRequestSchema = z.object({ - name: ZOrganisationNameSchema, + name: ZNameSchema, priceId: z.string().optional(), }); diff --git a/packages/trpc/server/organisation-router/delete-organisation-group.ts b/packages/trpc/server/organisation-router/delete-organisation-group.ts index b1230ab34..33bac9dc6 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-group.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-group.ts @@ -1,6 +1,7 @@ import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles'; +import { buildOrganisationWhereQuery, isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations'; import { prisma } from '@documenso/prisma'; import { OrganisationGroupType } from '@prisma/client'; @@ -59,6 +60,22 @@ export const deleteOrganisationGroupRoute = authenticatedProcedure }); } + const currentUserOrganisationRole = await getMemberOrganisationRole({ + organisationId, + reference: { + type: 'User', + id: user.id, + }, + }); + + // A user cannot delete a group whose role is higher than their own + // (e.g. a manager deleting an admin-role group). + if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, group.organisationRole)) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You are not allowed to delete this organisation group', + }); + } + await prisma.organisationGroup.delete({ where: { id: groupId, diff --git a/packages/trpc/server/organisation-router/delete-organisation-members.ts b/packages/trpc/server/organisation-router/delete-organisation-members.ts index dd5822265..d19e3b71b 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-members.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-members.ts @@ -1,8 +1,15 @@ import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; -import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { + ORGANISATION_MEMBER_ROLE_HIERARCHY, + ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP, +} from '@documenso/lib/constants/organisations'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { jobs } from '@documenso/lib/jobs/client'; -import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { + buildOrganisationWhereQuery, + getHighestOrganisationRoleInGroup, + isOrganisationRoleWithinUserHierarchy, +} from '@documenso/lib/utils/organisations'; import { prisma } from '@documenso/prisma'; import { OrganisationMemberInviteStatus } from '@documenso/prisma/client'; @@ -60,9 +67,12 @@ export const deleteOrganisationMembers = async ({ }, }, members: { - select: { - id: true, - userId: true, + include: { + organisationGroupMembers: { + include: { + group: true, + }, + }, }, }, invites: { @@ -84,6 +94,41 @@ export const deleteOrganisationMembers = async ({ const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id)); + const currentUserMember = organisation.members.find((member) => member.userId === userId); + + if (!currentUserMember) { + throw new AppError(AppErrorCode.UNAUTHORIZED); + } + + const currentUserOrganisationRole = getHighestOrganisationRoleInGroup( + currentUserMember.organisationGroupMembers.map(({ group }) => group), + ); + + // The roles the current user is allowed to act on (their own role and below). + const manageableOrganisationRoles = ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserOrganisationRole]; + + for (const member of membersToDelete) { + // The organisation owner can never be removed via this route. Ownership must + // be transferred first (mirrors the admin and update-member routes). + if (member.userId === organisation.ownerUserId) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Cannot remove the organisation owner', + }); + } + + const memberOrganisationRole = getHighestOrganisationRoleInGroup( + member.organisationGroupMembers.map(({ group }) => group), + ); + + // A user cannot remove a member whose role is higher than their own + // (e.g. a manager removing an admin). + if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, memberOrganisationRole)) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Cannot remove a member with a higher role', + }); + } + } + const inviteCount = organisation.invites.length; const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length; @@ -123,6 +168,18 @@ export const deleteOrganisationMembers = async ({ in: organisationMemberIds, }, organisationId, + userId: { + not: organisation.ownerUserId, + }, + organisationGroupMembers: { + none: { + group: { + organisationRole: { + notIn: manageableOrganisationRoles, + }, + }, + }, + }, }, }); }); diff --git a/packages/trpc/server/organisation-router/leave-organisation.ts b/packages/trpc/server/organisation-router/leave-organisation.ts index e04f5d26f..8e2b8b775 100644 --- a/packages/trpc/server/organisation-router/leave-organisation.ts +++ b/packages/trpc/server/organisation-router/leave-organisation.ts @@ -51,6 +51,14 @@ export const leaveOrganisationRoute = authenticatedProcedure throw new AppError(AppErrorCode.NOT_FOUND); } + // The organisation owner cannot leave their own organisation. Ownership must + // be transferred to another member first. + if (organisation.ownerUserId === userId) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You cannot leave an organisation you own. Please transfer ownership first.', + }); + } + const { organisationClaim } = organisation; const inviteCount = organisation.invites.length; diff --git a/packages/trpc/server/organisation-router/resend-organisation-member-invite.ts b/packages/trpc/server/organisation-router/resend-organisation-member-invite.ts index b3c2bdc68..e83c1658a 100644 --- a/packages/trpc/server/organisation-router/resend-organisation-member-invite.ts +++ b/packages/trpc/server/organisation-router/resend-organisation-member-invite.ts @@ -1,7 +1,8 @@ import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { sendOrganisationMemberInviteEmail } from '@documenso/lib/server-only/organisation/create-organisation-member-invites'; -import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles'; +import { buildOrganisationWhereQuery, isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations'; import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; @@ -97,6 +98,21 @@ export const resendOrganisationMemberInvitation = async ({ }); } + const currentUserOrganisationRole = await getMemberOrganisationRole({ + organisationId: organisation.id, + reference: { + type: 'User', + id: userId, + }, + }); + + // A user cannot interact with an invitation that is not within their own hierarchy. + if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, invitation.organisationRole)) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You cannot resend an invite for a member with a higher role', + }); + } + await sendOrganisationMemberInviteEmail({ email: invitation.email, token: invitation.token, diff --git a/packages/trpc/server/organisation-router/router.ts b/packages/trpc/server/organisation-router/router.ts index 3a66faab0..e0c1bbba4 100644 --- a/packages/trpc/server/organisation-router/router.ts +++ b/packages/trpc/server/organisation-router/router.ts @@ -20,6 +20,7 @@ import { getOrganisationsRoute } from './get-organisations'; import { leaveOrganisationRoute } from './leave-organisation'; import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite'; import { updateOrganisationRoute } from './update-organisation'; +import { updateOrganisationBrandingLogoRoute } from './update-organisation-branding-logo'; import { updateOrganisationGroupRoute } from './update-organisation-group'; import { updateOrganisationMemberRoute } from './update-organisation-members'; import { updateOrganisationSettingsRoute } from './update-organisation-settings'; @@ -55,6 +56,7 @@ export const organisationRouter = router({ }, settings: { update: updateOrganisationSettingsRoute, + updateBrandingLogo: updateOrganisationBrandingLogoRoute, }, internal: { getOrganisationSession: getOrganisationSessionRoute, diff --git a/packages/trpc/server/organisation-router/update-organisation-branding-logo.ts b/packages/trpc/server/organisation-router/update-organisation-branding-logo.ts new file mode 100644 index 000000000..a4ee8ffcd --- /dev/null +++ b/packages/trpc/server/organisation-router/update-organisation-branding-logo.ts @@ -0,0 +1,69 @@ +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo'; +import { getOrganisationClaim } from '@documenso/lib/server-only/organisation/get-organisation-claims'; +import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZUpdateOrganisationBrandingLogoRequestSchema, + ZUpdateOrganisationBrandingLogoResponseSchema, +} from './update-organisation-branding-logo.types'; + +export const updateOrganisationBrandingLogoRoute = authenticatedProcedure + .input(ZUpdateOrganisationBrandingLogoRequestSchema) + .output(ZUpdateOrganisationBrandingLogoResponseSchema) + .mutation(async ({ ctx, input }) => { + const { user } = ctx; + const { payload, brandingLogo } = input; + const { organisationId } = payload; + + ctx.logger.info({ + input: { + organisationId, + }, + }); + + const organisation = await prisma.organisation.findFirst({ + where: buildOrganisationWhereQuery({ + organisationId, + userId: user.id, + roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }), + }); + + if (!organisation) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to update this organisation.', + }); + } + + // Setting a logo requires the custom-branding entitlement; clearing it is + // always allowed so a downgraded organisation can still remove its logo. + if (brandingLogo && IS_BILLING_ENABLED()) { + const claim = await getOrganisationClaim({ organisationId }); + + if (claim.flags?.allowCustomBranding !== true) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Your plan does not allow custom branding.', + }); + } + } + + const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : ''; + + await prisma.organisation.update({ + where: { + id: organisation.id, + }, + data: { + organisationGlobalSettings: { + update: { + brandingLogo: brandingLogoValue, + }, + }, + }, + }); + }); diff --git a/packages/trpc/server/organisation-router/update-organisation-branding-logo.types.ts b/packages/trpc/server/organisation-router/update-organisation-branding-logo.types.ts new file mode 100644 index 000000000..059902921 --- /dev/null +++ b/packages/trpc/server/organisation-router/update-organisation-branding-logo.types.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; + +import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data'; + +export const ZUpdateOrganisationBrandingLogoRequestSchema = zodFormData({ + payload: zfd.json( + z.object({ + organisationId: z.string(), + }), + ), + brandingLogo: zfdBrandingImageFile().optional(), +}); + +export const ZUpdateOrganisationBrandingLogoResponseSchema = z.void(); + +export type TUpdateOrganisationBrandingLogoRequest = z.infer; diff --git a/packages/trpc/server/organisation-router/update-organisation-group.ts b/packages/trpc/server/organisation-router/update-organisation-group.ts index 06d45d763..c5be77bd2 100644 --- a/packages/trpc/server/organisation-router/update-organisation-group.ts +++ b/packages/trpc/server/organisation-router/update-organisation-group.ts @@ -38,6 +38,15 @@ export const updateOrganisationGroupRoute = authenticatedProcedure }, include: { organisationGroupMembers: true, + organisation: { + include: { + members: { + select: { + id: true, + }, + }, + }, + }, }, }); @@ -78,6 +87,15 @@ export const updateOrganisationGroupRoute = authenticatedProcedure const groupMemberIds = unique(data.memberIds || []); + // Validate that members belong to the same organisation as the group. + groupMemberIds.forEach((memberId) => { + const member = organisationGroup.organisation.members.find(({ id }) => id === memberId); + + if (!member) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + }); + const membersToDelete = organisationGroup.organisationGroupMembers.filter( (member) => !groupMemberIds.includes(member.organisationMemberId), ); diff --git a/packages/trpc/server/organisation-router/update-organisation-group.types.ts b/packages/trpc/server/organisation-router/update-organisation-group.types.ts index a32187edc..e6f4eb03f 100644 --- a/packages/trpc/server/organisation-router/update-organisation-group.types.ts +++ b/packages/trpc/server/organisation-router/update-organisation-group.types.ts @@ -1,3 +1,4 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { OrganisationMemberRole } from '@prisma/client'; import { z } from 'zod'; @@ -14,7 +15,7 @@ import { z } from 'zod'; export const ZUpdateOrganisationGroupRequestSchema = z.object({ id: z.string(), - name: z.string().nullable().optional(), + name: ZNameSchema.nullable().optional(), organisationRole: z.nativeEnum(OrganisationMemberRole).optional(), memberIds: z.array(z.string()).optional(), }); diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.ts b/packages/trpc/server/organisation-router/update-organisation-settings.ts index 625f79114..156e40e69 100644 --- a/packages/trpc/server/organisation-router/update-organisation-settings.ts +++ b/packages/trpc/server/organisation-router/update-organisation-settings.ts @@ -44,7 +44,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure // Branding related settings. brandingEnabled, - brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, @@ -174,7 +173,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure // Branding related settings. brandingEnabled, - brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors, diff --git a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts index d7b7d1c0d..62caa07fc 100644 --- a/packages/trpc/server/organisation-router/update-organisation-settings.types.ts +++ b/packages/trpc/server/organisation-router/update-organisation-settings.types.ts @@ -32,7 +32,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({ // Branding related settings. brandingEnabled: z.boolean().optional(), - brandingLogo: z.string().optional(), brandingUrl: z.string().optional(), brandingCompanyDetails: z.string().optional(), brandingColors: ZCssVarsSchema.nullish(), diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 4f1873096..5bcf2fe4e 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -1,4 +1,4 @@ -import { ZNameSchema } from '@documenso/lib/constants/auth'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { z } from 'zod'; export const ZFindUserSecurityAuditLogsSchema = z.object({ diff --git a/packages/trpc/server/team-router/create-team.types.ts b/packages/trpc/server/team-router/create-team.types.ts index efbedccfe..b4bc56679 100644 --- a/packages/trpc/server/team-router/create-team.types.ts +++ b/packages/trpc/server/team-router/create-team.types.ts @@ -1,5 +1,6 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { z } from 'zod'; -import { ZTeamNameSchema, ZTeamUrlSchema } from './schema'; +import { ZTeamUrlSchema } from './schema'; // export const createTeamMeta: TrpcOpenApiMeta = { // openapi: { @@ -13,7 +14,7 @@ import { ZTeamNameSchema, ZTeamUrlSchema } from './schema'; export const ZCreateTeamRequestSchema = z.object({ organisationId: z.string(), - teamName: ZTeamNameSchema, + teamName: ZNameSchema, teamUrl: ZTeamUrlSchema, inheritMembers: z .boolean() diff --git a/packages/trpc/server/team-router/delete-team-group.ts b/packages/trpc/server/team-router/delete-team-group.ts index a3953f4c1..50a3cc68b 100644 --- a/packages/trpc/server/team-router/delete-team-group.ts +++ b/packages/trpc/server/team-router/delete-team-group.ts @@ -53,6 +53,15 @@ export const deleteTeamGroupRoute = authenticatedProcedure }); } + // You cannot delete internal team groups. These are the system-managed + // admin/manager/member groups that back the team's role-based access, and + // deleting them would silently strip team members of their access. + if (group.organisationGroup.type === OrganisationGroupType.INTERNAL_TEAM) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You are not allowed to delete internal team groups', + }); + } + // You cannot delete internal organisation groups. // The only exception is deleting the "member" organisation group which is used to allow // all organisation members to access a team. diff --git a/packages/trpc/server/team-router/router.ts b/packages/trpc/server/team-router/router.ts index 26a1d9913..d05e2af13 100644 --- a/packages/trpc/server/team-router/router.ts +++ b/packages/trpc/server/team-router/router.ts @@ -25,6 +25,7 @@ import { ZUpdateTeamEmailMutationSchema, } from './schema'; import { updateTeamRoute } from './update-team'; +import { updateTeamBrandingLogoRoute } from './update-team-branding-logo'; import { updateTeamGroupRoute } from './update-team-group'; import { updateTeamMemberRoute } from './update-team-member'; import { updateTeamSettingsRoute } from './update-team-settings'; @@ -50,6 +51,7 @@ export const teamRouter = router({ }, settings: { update: updateTeamSettingsRoute, + updateBrandingLogo: updateTeamBrandingLogoRoute, }, // Old routes (to be migrated) diff --git a/packages/trpc/server/team-router/schema.ts b/packages/trpc/server/team-router/schema.ts index 35ee0b59a..620ad1cb4 100644 --- a/packages/trpc/server/team-router/schema.ts +++ b/packages/trpc/server/team-router/schema.ts @@ -1,5 +1,5 @@ -import { URL_PATTERN, ZNameSchema } from '@documenso/lib/constants/auth'; import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams'; +import { ZNameSchema } from '@documenso/lib/types/name'; import { zEmail } from '@documenso/lib/utils/zod'; import { TeamMemberRole } from '@prisma/client'; import { z } from 'zod'; @@ -32,15 +32,6 @@ export const ZTeamUrlSchema = z message: 'This URL is already in use.', }); -export const ZTeamNameSchema = z - .string() - .trim() - .min(3, { message: 'Team name must be at least 3 characters long.' }) - .max(30, { message: 'Team name must not exceed 30 characters.' }) - .refine((value) => !URL_PATTERN.test(value), { - message: 'Team name cannot contain URLs.', - }); - export const ZCreateTeamEmailVerificationMutationSchema = z.object({ teamId: z.number(), name: ZNameSchema, diff --git a/packages/trpc/server/team-router/update-team-branding-logo.ts b/packages/trpc/server/team-router/update-team-branding-logo.ts new file mode 100644 index 000000000..2196bb068 --- /dev/null +++ b/packages/trpc/server/team-router/update-team-branding-logo.ts @@ -0,0 +1,69 @@ +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildBrandingLogoData } from '@documenso/lib/server-only/branding/store-branding-logo'; +import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; + +import { authenticatedProcedure } from '../trpc'; +import { + ZUpdateTeamBrandingLogoRequestSchema, + ZUpdateTeamBrandingLogoResponseSchema, +} from './update-team-branding-logo.types'; + +export const updateTeamBrandingLogoRoute = authenticatedProcedure + .input(ZUpdateTeamBrandingLogoRequestSchema) + .output(ZUpdateTeamBrandingLogoResponseSchema) + .mutation(async ({ ctx, input }) => { + const { user } = ctx; + const { payload, brandingLogo } = input; + const { teamId } = payload; + + ctx.logger.info({ + input: { + teamId, + }, + }); + + const team = await prisma.team.findFirst({ + where: buildTeamWhereQuery({ + teamId, + userId: user.id, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }), + }); + + if (!team) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to update this team.', + }); + } + + // Setting a logo requires the custom-branding entitlement; clearing it is + // always allowed so a downgraded team can still remove its logo. + if (brandingLogo && IS_BILLING_ENABLED()) { + const claim = await getOrganisationClaimByTeamId({ teamId }); + + if (claim.flags?.allowCustomBranding !== true) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Your plan does not allow custom branding.', + }); + } + } + + const brandingLogoValue = brandingLogo ? await buildBrandingLogoData(brandingLogo) : ''; + + await prisma.team.update({ + where: { + id: team.id, + }, + data: { + teamGlobalSettings: { + update: { + brandingLogo: brandingLogoValue, + }, + }, + }, + }); + }); diff --git a/packages/trpc/server/team-router/update-team-branding-logo.types.ts b/packages/trpc/server/team-router/update-team-branding-logo.types.ts new file mode 100644 index 000000000..171537eaa --- /dev/null +++ b/packages/trpc/server/team-router/update-team-branding-logo.types.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; +import { zfd } from 'zod-form-data'; + +import { zfdBrandingImageFile, zodFormData } from '../../utils/zod-form-data'; + +export const ZUpdateTeamBrandingLogoRequestSchema = zodFormData({ + payload: zfd.json( + z.object({ + teamId: z.number(), + }), + ), + brandingLogo: zfdBrandingImageFile().optional(), +}); + +export const ZUpdateTeamBrandingLogoResponseSchema = z.void(); + +export type TUpdateTeamBrandingLogoRequest = z.infer; diff --git a/packages/trpc/server/team-router/update-team-group.ts b/packages/trpc/server/team-router/update-team-group.ts index f348d49fb..cd11f92d2 100644 --- a/packages/trpc/server/team-router/update-team-group.ts +++ b/packages/trpc/server/team-router/update-team-group.ts @@ -45,9 +45,12 @@ export const updateTeamGroupRoute = authenticatedProcedure }); } - if (teamGroup.organisationGroup.type === OrganisationGroupType.INTERNAL_ORGANISATION) { + if ( + teamGroup.organisationGroup.type === OrganisationGroupType.INTERNAL_ORGANISATION || + teamGroup.organisationGroup.type === OrganisationGroupType.INTERNAL_TEAM + ) { throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'You are not allowed to update internal organisation groups', + message: 'You are not allowed to update internal groups', }); } diff --git a/packages/trpc/server/team-router/update-team-settings.ts b/packages/trpc/server/team-router/update-team-settings.ts index c5db1d12a..c8a81b4b8 100644 --- a/packages/trpc/server/team-router/update-team-settings.ts +++ b/packages/trpc/server/team-router/update-team-settings.ts @@ -42,7 +42,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure // Branding related settings. brandingEnabled, - brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, @@ -176,7 +175,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure // Branding related settings. brandingEnabled, - brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors, diff --git a/packages/trpc/server/team-router/update-team-settings.types.ts b/packages/trpc/server/team-router/update-team-settings.types.ts index 31ec4c5b0..72990ce77 100644 --- a/packages/trpc/server/team-router/update-team-settings.types.ts +++ b/packages/trpc/server/team-router/update-team-settings.types.ts @@ -35,7 +35,6 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({ // Branding related settings. brandingEnabled: z.boolean().nullish(), - brandingLogo: z.string().nullish(), brandingUrl: z.string().nullish(), brandingCompanyDetails: z.string().nullish(), brandingColors: ZCssVarsSchema.nullish(), diff --git a/packages/trpc/server/team-router/update-team.types.ts b/packages/trpc/server/team-router/update-team.types.ts index c75abdf30..f28675b1b 100644 --- a/packages/trpc/server/team-router/update-team.types.ts +++ b/packages/trpc/server/team-router/update-team.types.ts @@ -1,6 +1,7 @@ +import { ZNameSchema } from '@documenso/lib/types/name'; import { z } from 'zod'; -import { ZTeamNameSchema, ZTeamUrlSchema } from './schema'; +import { ZTeamUrlSchema } from './schema'; export const MAX_PROFILE_BIO_LENGTH = 256; @@ -19,7 +20,7 @@ export const MAX_PROFILE_BIO_LENGTH = 256; export const ZUpdateTeamRequestSchema = z.object({ teamId: z.number(), data: z.object({ - name: ZTeamNameSchema.optional(), + name: ZNameSchema.optional(), url: ZTeamUrlSchema.optional(), profileBio: z .string() diff --git a/packages/trpc/server/webhook-router/schema.ts b/packages/trpc/server/webhook-router/schema.ts index 1abd126a4..87ebf53d2 100644 --- a/packages/trpc/server/webhook-router/schema.ts +++ b/packages/trpc/server/webhook-router/schema.ts @@ -1,4 +1,5 @@ import { isPrivateUrl } from '@documenso/lib/server-only/webhooks/is-private-url'; +import { URL_PATTERN } from '@documenso/lib/types/name'; import { WebhookTriggerEvents } from '@prisma/client'; import { z } from 'zod'; @@ -7,6 +8,13 @@ export const ZWebhookUrlSchema = z .url() .refine((url) => !isPrivateUrl(url), { message: 'Webhook URL cannot point to a private or loopback address', + }) + /* + * Without this, values like "foo: bar" would be valid URLs. + * Keep the same error message as the zod url() validator. + */ + .refine((value) => URL_PATTERN.test(value), { + message: 'Invalid url', }); export const ZCreateWebhookRequestSchema = z.object({ diff --git a/packages/trpc/utils/zod-form-data.ts b/packages/trpc/utils/zod-form-data.ts index d62c50635..82f3b39d2 100644 --- a/packages/trpc/utils/zod-form-data.ts +++ b/packages/trpc/utils/zod-form-data.ts @@ -1,4 +1,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app'; +import { + BRANDING_LOGO_ALLOWED_TYPES, + BRANDING_LOGO_MAX_SIZE_BYTES, + BRANDING_LOGO_MAX_SIZE_MB, +} from '@documenso/lib/constants/branding'; import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions'; import type { ZodRawShape } from 'zod'; import z from 'zod'; @@ -17,6 +22,21 @@ export const zfdFile = () => { }); }; +/** + * A `zfd.file()` schema constrained to branding-logo images: size-limited and + * restricted to a MIME allowlist. Use for server-side branding logo uploads. + */ +export const zfdBrandingImageFile = () => { + return zfd + .file() + .refine((file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES, { + message: `File cannot be larger than ${BRANDING_LOGO_MAX_SIZE_MB}MB`, + }) + .refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), { + message: 'File must be a JPG, PNG, or WebP image', + }); +}; + /** * This helper takes the place of the `z.object` at the root of your schema. * It wraps your schema in a `z.preprocess` that extracts all the data out of a `FormData` diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 6757ed47c..d02df2be0 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -93,6 +93,13 @@ declare namespace NodeJS { NEXT_PUBLIC_DISABLE_OIDC_SIGNUP?: string; NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS?: string; + NEXT_PUBLIC_DISABLE_SIGNIN?: string; + NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN?: string; + NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN?: string; + NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN?: string; + NEXT_PUBLIC_DISABLE_OIDC_SIGNIN?: string; + NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT?: string; + NEXT_PRIVATE_BROWSERLESS_URL?: string; NEXT_PRIVATE_JOBS_PROVIDER?: 'inngest' | 'local' | 'bullmq'; diff --git a/packages/ui/primitives/alert-dialog.tsx b/packages/ui/primitives/alert-dialog.tsx index 0bd424445..77c1cfaa2 100644 --- a/packages/ui/primitives/alert-dialog.tsx +++ b/packages/ui/primitives/alert-dialog.tsx @@ -41,7 +41,7 @@ const AlertDialogContent = React.forwardRef< +