From 94adea149de72c2abc9bdfe039b844d25b821117 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 25 Jun 2026 23:59:25 +1000 Subject: [PATCH 01/12] chore: general repo maintenance and docs cleanup (#3030) Refresh README/docs for the current stack, add a security policy, note the external-PR pause, and remove dead config and workflows. --- .env.example | 2 +- .github/ISSUE_TEMPLATE/bug-report.yml | 3 +- .github/ISSUE_TEMPLATE/config.yml | 11 ++ .github/ISSUE_TEMPLATE/feature-request.yml | 1 - .github/ISSUE_TEMPLATE/improvement.yml | 11 -- .github/PULL_REQUEST_TEMPLATE.md | 11 ++ .../PULL_REQUEST_TEMPLATE/test-addition.md | 40 ------- .github/workflows/first-interaction.yml | 8 +- .github/workflows/issue-assignee-check.yml | 62 ----------- .github/workflows/pr-review-reminder.yml | 63 ----------- .github/workflows/semantic-pull-requests.yml | 27 ----- .github/workflows/stale.yml | 2 +- .gitpod.yml | 3 +- .well-known/security.txt | 15 ++- CONTRIBUTING.md | 26 +++-- README.md | 34 +++--- SECURITY.md | 38 +++++++ SIGNING.md | 70 ++---------- apps/docs/README.md | 47 ++------ .../developers/local-development/index.mdx | 15 +-- .../docs/self-hosting/configuration/email.mdx | 4 +- .../configuration/environment.mdx | 15 +++ .../self-hosting/deployment/kubernetes.mdx | 2 +- .../docs/self-hosting/deployment/manual.mdx | 4 +- .../getting-started/quick-start.mdx | 16 +-- .../getting-started/requirements.mdx | 6 +- .../self-hosting/getting-started/tips.mdx | 2 +- apps/docs/content/docs/self-hosting/index.mdx | 8 ++ .../self-hosting/maintenance/upgrades.mdx | 20 ++-- apps/remix/Dockerfile | 22 ---- apps/remix/README.md | 100 ++---------------- apps/remix/public/.well-known/security.txt | 15 ++- package.json | 1 - .../webhooks/assert-webhook-url.ts | 13 ++- .../server-only/webhooks/is-private-url.ts | 7 +- render.yaml | 2 +- tsconfig.eslint.json | 4 - 37 files changed, 227 insertions(+), 503 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/PULL_REQUEST_TEMPLATE/test-addition.md delete mode 100644 .github/workflows/issue-assignee-check.yml delete mode 100644 .github/workflows/pr-review-reminder.yml create mode 100644 SECURITY.md delete mode 100644 apps/remix/Dockerfile delete mode 100644 tsconfig.eslint.json diff --git a/.env.example b/.env.example index aa6565f8c..be3ecaab3 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" 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/.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/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/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..4ec25e213 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 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/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..3f8f8d3c2 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 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/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/public/.well-known/security.txt b/apps/remix/public/.well-known/security.txt index 1a3f685e5..f96fce0f0 100644 --- a/apps/remix/public/.well-known/security.txt +++ b/apps/remix/public/.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/package.json b/package.json index 173c46bb1..e32496f6b 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "with:env": "dotenv -e .env -e .env.local --", "reset:hard": "npm run clean && npm i && npm run prisma:generate", "precommit": "npm install && git add package.json package-lock.json", - "trigger:dev": "npm run with:env -- npx trigger-cli dev --handler-path=\"/api/jobs\"", "inngest:dev": "inngest dev -u http://localhost:3000/api/jobs", "make:version": "npm version --workspace @documenso/remix --include-workspace-root --no-git-tag-version -m \"v%s\"", "translate": "npm run translate:extract && npm run translate:compile", 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/render.yaml b/render.yaml index 1811be308..9a29fae4d 100644 --- a/render.yaml +++ b/render.yaml @@ -10,7 +10,7 @@ services: envVars: # Node Version - key: NODE_VERSION - value: 18.17.0 + value: 22.13.0 - key: PORT value: 10000 diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json deleted file mode 100644 index 9974f0b6a..000000000 --- a/tsconfig.eslint.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./packages/tsconfig/base.json", - "include": ["packages/**/*", "apps/**/*"] -} From 241929bb97109c03b56e96b432983737a9e3fb57 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 26 Jun 2026 00:01:55 +1000 Subject: [PATCH 02/12] feat: add API endpoints for downloading certificate and audit log PDFs (#3025) Add V2 API routes to download an envelope's certificate and audit log separately, and align the internal cert/audit log downloads to use envelopeId. Enforces document visibility via getEnvelopeWhereInput and loads field signatures so certificates render correctly. --- .../document-audit-log-download-button.tsx | 6 +- .../document-certificate-download-button.tsx | 6 +- .../t.$teamUrl+/documents.$id.logs.tsx | 4 +- apps/remix/server/api/download/download.ts | 252 +++++++++++++++--- .../server/api/download/download.types.ts | 14 + .../download-document-audit-logs.ts | 12 +- .../download-document-audit-logs.types.ts | 2 +- .../download-document-certificate.ts | 12 +- .../download-document-certificate.types.ts | 2 +- .../download-envelope-audit-log-pdf.ts | 23 ++ .../download-envelope-audit-log-pdf.types.ts | 25 ++ .../download-envelope-certificate-pdf.ts | 23 ++ ...download-envelope-certificate-pdf.types.ts | 25 ++ .../trpc/server/envelope-router/router.ts | 6 + 14 files changed, 348 insertions(+), 64 deletions(-) create mode 100644 packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.ts create mode 100644 packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.types.ts create mode 100644 packages/trpc/server/envelope-router/download-envelope-certificate-pdf.ts create mode 100644 packages/trpc/server/envelope-router/download-envelope-certificate-pdf.types.ts diff --git a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx index 109c3b9f4..695b31364 100644 --- a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx +++ b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx @@ -11,10 +11,10 @@ import { DownloadIcon } from 'lucide-react'; export type DocumentAuditLogDownloadButtonProps = { className?: string; - documentId: number; + envelopeId: string; }; -export const DocumentAuditLogDownloadButton = ({ className, documentId }: DocumentAuditLogDownloadButtonProps) => { +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/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx index 2ed2ca142..9b8045bc0 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -153,11 +153,11 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
- +
diff --git a/apps/remix/server/api/download/download.ts b/apps/remix/server/api/download/download.ts index 012d9e875..796a68653 100644 --- a/apps/remix/server/api/download/download.ts +++ b/apps/remix/server/api/download/download.ts @@ -1,20 +1,52 @@ +import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { getEnvelopeById, getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf'; +import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token'; +import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { sValidator } from '@hono/standard-validator'; -import { EnvelopeType } from '@prisma/client'; +import { DocumentStatus, EnvelopeType } from '@prisma/client'; +import contentDisposition from 'content-disposition'; import { Hono } from 'hono'; import type { HonoEnv } from '../../router'; import { handleEnvelopeItemFileRequest } from '../files/files.helpers'; import { ZDownloadDocumentRequestParamsSchema, + ZDownloadEnvelopeAuditLogPdfRequestParamsSchema, + ZDownloadEnvelopeCertificatePdfRequestParamsSchema, ZDownloadEnvelopeItemRequestParamsSchema, ZDownloadEnvelopeItemRequestQuerySchema, } from './download.types'; +/** + * Resolve and validate an API token from the Authorization header. + * + * Supports both "Authorization: Bearer api_xxx" and "Authorization: api_xxx". + */ +const resolveApiToken = async (authorizationHeader: string | undefined) => { + const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0); + + if (!token) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'API token was not provided', + }); + } + + const apiToken = await getApiTokenByToken({ token }); + + if (apiToken.user.disabled) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'User is disabled', + }); + } + + return apiToken; +}; + export const downloadRoute = new Hono() /** * Download an envelope item by its ID. @@ -30,24 +62,8 @@ export const downloadRoute = new Hono() try { const { envelopeItemId } = c.req.valid('param'); const { version } = c.req.valid('query'); - const authorizationHeader = c.req.header('authorization'); - // Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx" - const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0); - - if (!token) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'API token was not provided', - }); - } - - const apiToken = await getApiTokenByToken({ token }); - - if (apiToken.user.disabled) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'User is disabled', - }); - } + const apiToken = await resolveApiToken(c.req.header('authorization')); logger.info({ auth: 'api', @@ -125,6 +141,180 @@ export const downloadRoute = new Hono() } }, ) + /** + * Download the audit log for a document as a PDF. + * Requires API key authentication via Authorization header. + */ + .get( + '/envelope/:envelopeId/audit-log/pdf', + sValidator('param', ZDownloadEnvelopeAuditLogPdfRequestParamsSchema), + async (c) => { + const logger = c.get('logger'); + + try { + const { envelopeId } = c.req.valid('param'); + + const apiToken = await resolveApiToken(c.req.header('authorization')); + + logger.info({ + auth: 'api', + source: 'apiV2', + path: c.req.path, + userId: apiToken.user.id, + apiTokenId: apiToken.id, + envelopeId, + }); + + const envelope = await getEnvelopeById({ + id: { + type: 'envelopeId', + id: envelopeId, + }, + type: EnvelopeType.DOCUMENT, + userId: apiToken.user.id, + teamId: apiToken.teamId, + }).catch(() => null); + + if (!envelope) { + return c.json({ error: 'Document not found' }, 404); + } + + const auditLogPdf = await generateAuditLogPdf({ + envelope, + recipients: envelope.recipients, + fields: envelope.fields, + language: envelope.documentMeta.language, + envelopeOwner: { + email: envelope.user.email, + name: envelope.user.name || '', + }, + envelopeItems: envelope.envelopeItems.map((item) => item.title), + pageWidth: PDF_SIZE_A4_72PPI.width, + pageHeight: PDF_SIZE_A4_72PPI.height, + }); + + const result = await auditLogPdf.save(); + + const baseTitle = envelope.title.replace(/\.pdf$/i, ''); + + c.header('Content-Type', 'application/pdf'); + c.header('Content-Disposition', contentDisposition(`${baseTitle}_audit-log.pdf`)); + c.header('Cache-Control', 'no-cache, no-store, must-revalidate'); + + return c.body(result); + } catch (error) { + logger.error(error); + + if (error instanceof AppError) { + const { status, body } = AppError.toRestAPIError(error); + + return c.json({ error: body.message, code: error.code }, status); + } + + return c.json({ error: 'Internal server error' }, 500); + } + }, + ) + /** + * Download the signing certificate for a completed document as a PDF. + * Requires API key authentication via Authorization header. + */ + .get( + '/envelope/:envelopeId/certificate/pdf', + sValidator('param', ZDownloadEnvelopeCertificatePdfRequestParamsSchema), + async (c) => { + const logger = c.get('logger'); + + try { + const { envelopeId } = c.req.valid('param'); + + const apiToken = await resolveApiToken(c.req.header('authorization')); + + logger.info({ + auth: 'api', + source: 'apiV2', + path: c.req.path, + userId: apiToken.user.id, + apiTokenId: apiToken.id, + envelopeId, + }); + + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'envelopeId', + id: envelopeId, + }, + type: EnvelopeType.DOCUMENT, + userId: apiToken.user.id, + teamId: apiToken.teamId, + }); + + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + include: { + recipients: true, + fields: { + include: { + signature: true, + }, + }, + documentMeta: true, + user: { + select: { + email: true, + name: true, + }, + }, + }, + }); + + if (!envelope) { + return c.json({ error: 'Document not found' }, 404); + } + + // A cancelled document was never sealed/completed, so a signing certificate + // must not be generated for it. + if (!isDocumentCompleted(envelope.status) || envelope.status === DocumentStatus.CANCELLED) { + throw new AppError('DOCUMENT_NOT_COMPLETE', { + message: 'Document is not complete', + }); + } + + const certificatePdf = await generateCertificatePdf({ + envelope, + recipients: envelope.recipients, + fields: envelope.fields, + language: envelope.documentMeta.language, + envelopeOwner: { + email: envelope.user.email, + name: envelope.user.name || '', + }, + pageWidth: PDF_SIZE_A4_72PPI.width, + pageHeight: PDF_SIZE_A4_72PPI.height, + }); + + const result = await certificatePdf.save(); + + const baseTitle = envelope.title.replace(/\.pdf$/i, ''); + + c.header('Content-Type', 'application/pdf'); + c.header('Content-Disposition', contentDisposition(`${baseTitle}_certificate.pdf`)); + c.header('Cache-Control', 'no-cache, no-store, must-revalidate'); + + return c.body(result); + } catch (error) { + logger.error(error); + + if (error instanceof AppError) { + const { status, body } = AppError.toRestAPIError(error); + + return c.json({ error: body.message, code: error.code }, status); + } + + return c.json({ error: 'Internal server error' }, 500); + } + }, + ) /** * Download a document by its ID. * Requires API key authentication via Authorization header. @@ -134,24 +324,8 @@ export const downloadRoute = new Hono() try { const { documentId, version } = c.req.valid('param'); - const authorizationHeader = c.req.header('authorization'); - // Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx" - const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0); - - if (!token) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'API token was not provided', - }); - } - - const apiToken = await getApiTokenByToken({ token }); - - if (apiToken.user.disabled) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'User is disabled', - }); - } + const apiToken = await resolveApiToken(c.req.header('authorization')); logger.info({ auth: 'api', @@ -200,11 +374,9 @@ export const downloadRoute = new Hono() logger.error(error); if (error instanceof AppError) { - if (error.code === AppErrorCode.UNAUTHORIZED) { - return c.json({ error: error.message }, 401); - } + const { status, body } = AppError.toRestAPIError(error); - return c.json({ error: error.message }, 400); + return c.json({ error: body.message, code: error.code }, status); } return c.json({ error: 'Internal server error' }, 500); diff --git a/apps/remix/server/api/download/download.types.ts b/apps/remix/server/api/download/download.types.ts index 86a827d31..d5a64d468 100644 --- a/apps/remix/server/api/download/download.types.ts +++ b/apps/remix/server/api/download/download.types.ts @@ -30,3 +30,17 @@ export const ZDownloadDocumentRequestParamsSchema = z.object({ }); export type TDownloadDocumentRequestParams = z.infer; + +export const ZDownloadEnvelopeAuditLogPdfRequestParamsSchema = z.object({ + envelopeId: z.string().describe('The ID of the envelope to download the audit log for.'), +}); + +export type TDownloadEnvelopeAuditLogPdfRequestParams = z.infer; + +export const ZDownloadEnvelopeCertificatePdfRequestParamsSchema = z.object({ + envelopeId: z.string().describe('The ID of the envelope to download the certificate for.'), +}); + +export type TDownloadEnvelopeCertificatePdfRequestParams = z.infer< + typeof ZDownloadEnvelopeCertificatePdfRequestParamsSchema +>; 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/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..3e43033ac --- /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/pdf', + 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..eeab2be69 --- /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/pdf', + 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/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, From 96ab78c33f617e43db1a8ba84396f584e815fc8e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Jun 2026 14:46:30 +1000 Subject: [PATCH 03/12] fix: resolve permission issues (#3029) --- .../organisation-permission-hierarchy.spec.ts | 342 ++++++++++++++++++ .../delete-organisation-group.ts | 19 +- .../delete-organisation-members.ts | 67 +++- .../organisation-router/leave-organisation.ts | 8 + .../resend-organisation-member-invite.ts | 18 +- 5 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts 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..b688c7cd9 --- /dev/null +++ b/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts @@ -0,0 +1,342 @@ +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(); + }); +}); 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, From c219305eb1b60a9df4232b602434045318246344 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sat, 27 Jun 2026 19:54:14 +1000 Subject: [PATCH 04/12] chore: add tests for cert and audit log download via api (#3043) --- apps/remix/server/api/download/download.ts | 4 +- ...api-access-envelope-cert-audit-log.spec.ts | 284 ++++++++++++++++++ .../download-envelope-audit-log-pdf.types.ts | 2 +- ...download-envelope-certificate-pdf.types.ts | 2 +- 4 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-cert-audit-log.spec.ts diff --git a/apps/remix/server/api/download/download.ts b/apps/remix/server/api/download/download.ts index 796a68653..6682c327c 100644 --- a/apps/remix/server/api/download/download.ts +++ b/apps/remix/server/api/download/download.ts @@ -146,7 +146,7 @@ export const downloadRoute = new Hono() * Requires API key authentication via Authorization header. */ .get( - '/envelope/:envelopeId/audit-log/pdf', + '/envelope/:envelopeId/audit-log/download', sValidator('param', ZDownloadEnvelopeAuditLogPdfRequestParamsSchema), async (c) => { const logger = c.get('logger'); @@ -220,7 +220,7 @@ export const downloadRoute = new Hono() * Requires API key authentication via Authorization header. */ .get( - '/envelope/:envelopeId/certificate/pdf', + '/envelope/:envelopeId/certificate/download', sValidator('param', ZDownloadEnvelopeCertificatePdfRequestParamsSchema), async (c) => { const logger = c.get('logger'); diff --git a/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-cert-audit-log.spec.ts b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-cert-audit-log.spec.ts new file mode 100644 index 000000000..17216ee67 --- /dev/null +++ b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-cert-audit-log.spec.ts @@ -0,0 +1,284 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { hashString } from '@documenso/lib/server-only/auth/hash'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +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 { seedUser } from '@documenso/prisma/seed/users'; +import { type APIRequestContext, expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; + +/** + * Create an API token directly, bypassing the role check in `createApiToken`. + * + * This simulates a token that was minted while the user had permission, and which + * survives a later downgrade to a lower team role (e.g. MEMBER). Such a token must + * still respect document visibility at request time. + */ +const seedApiTokenForUser = async ({ + userId, + teamId, + tokenName, +}: { + userId: number; + teamId: number; + tokenName: string; +}) => { + const token = `api_${alphaid(16)}`; + + await prisma.apiToken.create({ + data: { + name: tokenName, + token: hashString(token), + expires: null, + userId, + teamId, + }, + }); + + return { token }; +}; + +test.describe.configure({ + mode: 'parallel', +}); + +const downloadAuditLogPdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => { + return request.get(`${API_BASE_URL}/envelope/${envelopeId}/audit-log/download`, { + headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, + }); +}; + +const downloadCertificatePdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => { + return request.get(`${API_BASE_URL}/envelope/${envelopeId}/certificate/download`, { + headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, + }); +}; + +test.describe('Envelope certificate / audit log PDF download API V2 - access control', () => { + let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'userA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'userB', + expiresIn: null, + })); + }); + + test('should reject audit log download without an API token', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + const res = await downloadAuditLogPdf(request, document.id); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('should reject certificate download without an API token', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + const res = await downloadCertificatePdf(request, document.id); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('should reject audit log download from a user in a different team', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + const res = await downloadAuditLogPdf(request, document.id, tokenB); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should reject certificate download from a user in a different team', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + const res = await downloadCertificatePdf(request, document.id, tokenB); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should reject a disabled user downloading the audit log', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + await prisma.user.update({ + where: { id: userA.id }, + data: { disabled: true }, + }); + + const res = await downloadAuditLogPdf(request, document.id, tokenA); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('should reject a disabled user downloading the certificate', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + await prisma.user.update({ + where: { id: userA.id }, + data: { disabled: true }, + }); + + const res = await downloadCertificatePdf(request, document.id, tokenA); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('should return 404 for a non-existent envelope id', async ({ request }) => { + const auditLogRes = await downloadAuditLogPdf(request, 'envelope_doesnotexist', tokenA); + expect(auditLogRes.status()).toBe(404); + + const certificateRes = await downloadCertificatePdf(request, 'envelope_doesnotexist', tokenA); + expect(certificateRes.status()).toBe(404); + }); +}); + +test.describe('Envelope certificate / audit log PDF download API V2 - document visibility', () => { + test.describe.configure({ + mode: 'parallel', + }); + + test('should hide an ADMIN-only document from a downgraded member (audit log)', 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, + tokenName: 'member-audit-log-token', + }); + + // ADMIN-visibility document owned by the team owner - a member must not see it. + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const res = await downloadAuditLogPdf(request, document.id, memberToken); + + // Visibility failure surfaces as not-found, matching the canonical access checks. + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should hide an ADMIN-only document from a downgraded member (certificate)', 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, + tokenName: 'member-certificate-token', + }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const res = await downloadCertificatePdf(request, document.id, memberToken); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should hide a MANAGER_AND_ABOVE document from a downgraded member (audit log)', 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, + tokenName: 'member-manager-vis-token', + }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE }, + }); + + const res = await downloadAuditLogPdf(request, document.id, memberToken); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should hide a MANAGER_AND_ABOVE document from a downgraded member (certificate)', 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, + tokenName: 'member-manager-vis-cert-token', + }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE }, + }); + + const res = await downloadCertificatePdf(request, document.id, memberToken); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should hide an ADMIN-only document from a downgraded manager (certificate)', async ({ request }) => { + const { team, owner } = await seedTeam(); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + const { token: managerToken } = await seedApiTokenForUser({ + userId: manager.id, + teamId: team.id, + tokenName: 'manager-admin-vis-cert-token', + }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const res = await downloadCertificatePdf(request, document.id, managerToken); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow a member to download an EVERYONE-visibility document (audit log)', 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, + tokenName: 'member-everyone-vis-token', + }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.EVERYONE }, + }); + + const res = await downloadAuditLogPdf(request, document.id, memberToken); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + expect(res.headers()['content-type']).toContain('application/pdf'); + }); +}); 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 index 3e43033ac..2c725d7f8 100644 --- 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 @@ -5,7 +5,7 @@ import type { TrpcRouteMeta } from '../trpc'; export const downloadEnvelopeAuditLogPdfMeta: TrpcRouteMeta = { openapi: { method: 'GET', - path: '/envelope/{envelopeId}/audit-log/pdf', + 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'], 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 index eeab2be69..b35e7aa11 100644 --- a/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.types.ts +++ b/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.types.ts @@ -5,7 +5,7 @@ import type { TrpcRouteMeta } from '../trpc'; export const downloadEnvelopeCertificatePdfMeta: TrpcRouteMeta = { openapi: { method: 'GET', - path: '/envelope/{envelopeId}/certificate/pdf', + path: '/envelope/{envelopeId}/certificate/download', summary: 'Download envelope certificate PDF', description: 'Download the signing certificate for a completed document as a PDF.', tags: ['Envelope'], From 037170f6253d8b2bdeaf2eb0a08d04f152a41a58 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sun, 28 Jun 2026 12:38:38 +1000 Subject: [PATCH 05/12] v2.14.0 --- apps/remix/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/remix/package.json b/apps/remix/package.json index 2f4d6fea0..22a92ee34 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -106,5 +106,5 @@ "vite-plugin-babel-macros": "^1.0.6", "vite-tsconfig-paths": "^5.1.4" }, - "version": "2.13.0" + "version": "2.14.0" } diff --git a/package-lock.json b/package-lock.json index b43306e42..cb4e523f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "2.13.0", + "version": "2.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "2.13.0", + "version": "2.14.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -406,7 +406,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "2.13.0", + "version": "2.14.0", "dependencies": { "@cantoo/pdf-lib": "^2.5.3", "@documenso/api": "*", diff --git a/package.json b/package.json index e32496f6b..d0ffcb2b5 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "apps/*", "packages/*" ], - "version": "2.13.0", + "version": "2.14.0", "scripts": { "postinstall": "patch-package", "build": "turbo run build", From 977d07330b97ce451fb834447807b9d4163fc6bd Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Sun, 28 Jun 2026 15:07:33 +1000 Subject: [PATCH 06/12] fix: auto select field on drop (#3028) --- .../envelope-editor-fields-page-renderer.tsx | 89 ++++++++++++++----- .../envelope-fields.spec.ts | 1 + 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index db8f6ffbb..5cbd4addf 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -49,6 +49,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR const [isFieldChanging, setIsFieldChanging] = useState(false); const [pendingFieldCreation, setPendingFieldCreation] = useState(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, @@ -521,13 +528,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 +705,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 && (
Date: Sun, 28 Jun 2026 15:08:11 +1000 Subject: [PATCH 07/12] feat: add field multiselect (#3031) --- .../envelope-editor-fields-page-renderer.tsx | 57 +++---- .../envelope-fields.spec.ts | 151 +++++++++++++++++- packages/app-tests/e2e/fixtures/konva.ts | 32 ++++ 3 files changed, 206 insertions(+), 34 deletions(-) diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx index 5cbd4addf..698f83651 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx @@ -244,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(); }); @@ -452,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); } }); 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 f9c3515d4..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; @@ -99,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()}`; @@ -761,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); @@ -816,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); @@ -868,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', @@ -945,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/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 }, + ); +}; From 381293af0c6b198eef0b2ae3cfca87b754c287b1 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sun, 28 Jun 2026 22:01:20 +1000 Subject: [PATCH 08/12] fix: invite email placeholder (#3045) - **fix: interpolate inviterEmail in invite email mailto link** - **fix: add alt attributes to email template images** --- .../template-document-completed.tsx | 8 ++++++-- .../template-document-pending.tsx | 2 +- .../template-document-recipient-signed.tsx | 6 +++++- .../template-document-self-signed.tsx | 14 +++++++++++--- .../email/template-components/template-image.tsx | 2 +- packages/email/templates/document-invite.tsx | 2 +- 6 files changed, 25 insertions(+), 9 deletions(-) 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-pending.tsx b/packages/email/template-components/template-document-pending.tsx index 5022b8b87..c44ae5c81 100644 --- a/packages/email/template-components/template-document-pending.tsx +++ b/packages/email/template-components/template-document-pending.tsx @@ -21,7 +21,7 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
- + Waiting for others diff --git a/packages/email/template-components/template-document-recipient-signed.tsx b/packages/email/template-components/template-document-recipient-signed.tsx index c35a0f316..42ebe0e10 100644 --- a/packages/email/template-components/template-document-recipient-signed.tsx +++ b/packages/email/template-components/template-document-recipient-signed.tsx @@ -30,7 +30,11 @@ export const TemplateDocumentRecipientSigned = ({
- + Completed diff --git a/packages/email/template-components/template-document-self-signed.tsx b/packages/email/template-components/template-document-self-signed.tsx index 4759ea06f..852843577 100644 --- a/packages/email/template-components/template-document-self-signed.tsx +++ b/packages/email/template-components/template-document-self-signed.tsx @@ -26,7 +26,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
- + Completed @@ -51,7 +55,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ href={signUpUrl} className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline" > - + Create account @@ -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/document-invite.tsx b/packages/email/templates/document-invite.tsx index 5e8d3a6d4..277f28859 100644 --- a/packages/email/templates/document-invite.tsx +++ b/packages/email/templates/document-invite.tsx @@ -85,7 +85,7 @@ export const DocumentInviteEmailTemplate = ({ {inviterName}{' '} - + ({inviterEmail}) From 8f683932419341683b57be1c056dbda12df8b598 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 29 Jun 2026 13:15:13 +1000 Subject: [PATCH 09/12] fix: tighten permission and validation checks (#3046) --- .../document-signing-attachments-popover.tsx | 3 +- .../document/document-attachments-popover.tsx | 5 +- .../embedded-editor-attachment-popover.tsx | 5 +- .../document-visibility-access.spec.ts | 89 +++++++++++++ .../attachment-url-validation.spec.ts | 70 ++++++++++ .../attachment-visibility-access.spec.ts | 121 ++++++++++++++++++ .../organisation-permission-hierarchy.spec.ts | 64 +++++++++ .../recipient-visibility-access.spec.ts | 72 +++++++++++ .../e2e/teams/team-profile-access.spec.ts | 53 ++++++++ .../server-only/document/cancel-document.ts | 28 ++-- .../server-only/document/delete-document.ts | 35 +++-- .../envelope-attachment/create-attachment.ts | 14 +- .../envelope-attachment/delete-attachment.ts | 23 +++- .../find-attachments-by-envelope-id.ts | 14 +- .../envelope-attachment/update-attachment.ts | 23 +++- .../public-api/get-user-by-token.ts | 44 ------- .../recipient/get-recipient-by-id.ts | 22 ++++ .../share/create-or-get-share-link.ts | 27 ++++ .../team/get-team-public-profile.ts | 24 ++-- .../team/update-team-public-profile.ts | 7 +- .../lib/server-only/user/forgot-password.ts | 41 +++--- .../server-only/webhooks/zapier/subscribe.ts | 40 +++++- .../webhooks/zapier/unsubscribe.ts | 30 ++++- packages/lib/utils/is-http-url.test.ts | 45 +++++++ packages/lib/utils/is-http-url.ts | 32 +++++ .../attachment/create-attachment.types.ts | 3 +- .../attachment/update-attachment.types.ts | 3 +- .../get-envelope-recipient.ts | 22 ++++ .../update-organisation-group.ts | 18 +++ 29 files changed, 835 insertions(+), 142 deletions(-) create mode 100644 packages/app-tests/e2e/documents/document-visibility-access.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/attachment-url-validation.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/attachment-visibility-access.spec.ts create mode 100644 packages/app-tests/e2e/recipient/recipient-visibility-access.spec.ts create mode 100644 packages/app-tests/e2e/teams/team-profile-access.spec.ts delete mode 100644 packages/lib/server-only/public-api/get-user-by-token.ts create mode 100644 packages/lib/utils/is-http-url.test.ts create mode 100644 packages/lib/utils/is-http-url.ts 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}

; @@ -117,7 +118,7 @@ export const EmbeddedEditorAttachmentPopover = ({

{attachment.label}

{ + try { + await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: envelopeId }, + userId, + teamId, + type: null, + }).then(({ envelopeWhereInput }) => prisma.envelope.findFirstOrThrow({ where: envelopeWhereInput })); + + return true; + } catch { + return false; + } +}; + +test('[DOCUMENTS]: a member cannot delete a document with restricted visibility', async () => { + const { user: owner, team } = await seedUser(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { + visibility: DocumentVisibility.ADMIN, + status: DocumentStatus.DRAFT, + }, + }); + + // The member cannot read an ADMIN-visibility document, so they must not be + // able to delete it either. + expect(await canReadEnvelope(envelope.id, member.id, team.id)).toBe(false); + + await expect( + deleteDocument({ + id: { type: 'envelopeId', id: envelope.id }, + userId: member.id, + teamId: team.id, + requestMetadata, + }), + ).rejects.toThrow(); + + const stillExists = await prisma.envelope.findUnique({ where: { id: envelope.id } }); + expect(stillExists).not.toBeNull(); +}); + +test('[DOCUMENTS]: a manager cannot cancel a document with restricted visibility', async () => { + const { user: owner, team } = await seedUser(); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { + visibility: DocumentVisibility.ADMIN, + status: DocumentStatus.PENDING, + }, + }); + + // A manager outranks a member but still cannot read an ADMIN-visibility + // document, so cancellation must be blocked despite the sufficient role. + expect(await canReadEnvelope(envelope.id, manager.id, team.id)).toBe(false); + + await expect( + cancelDocument({ + id: { type: 'envelopeId', id: envelope.id }, + userId: manager.id, + teamId: team.id, + reason: 'test-cancel', + requestMetadata, + }), + ).rejects.toThrow(); + + const after = await prisma.envelope.findUnique({ where: { id: envelope.id } }); + expect(after?.status).toBe(DocumentStatus.PENDING); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/attachment-url-validation.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/attachment-url-validation.spec.ts new file mode 100644 index 000000000..c3228a057 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/attachment-url-validation.spec.ts @@ -0,0 +1,70 @@ +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 { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam } 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: 'attachment-url-test', token: hashString(token), expires: null, userId, teamId }, + }); + + return { token }; +}; + +/** + * Attachment URLs are rendered as link hrefs, so they must be restricted to + * http(s). The API must reject any other scheme. + */ +const NON_HTTP_URLS = [ + 'javascript:alert(document.cookie)', + 'data:text/html,', + '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/organisations/organisation-permission-hierarchy.spec.ts b/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts index b688c7cd9..23483a48e 100644 --- a/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts +++ b/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts @@ -340,3 +340,67 @@ test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', () 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/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/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/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/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 8eed2702f..0a4456d5c 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -13,7 +13,7 @@ import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; import { isRecipientEmailValidForSending } from '../../utils/recipients'; 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 = { @@ -36,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: { @@ -51,27 +53,36 @@ 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) { + if (hasDeleteAccess) { await handleDocumentOwnerDelete({ envelope, user, 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/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/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/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/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/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/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/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/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/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/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), ); From 1f170ef5e543480d547ab34c73b3ddd76cf90062 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 29 Jun 2026 14:11:31 +1000 Subject: [PATCH 10/12] fix: scope organisation group deletion (#3047) --- packages/lib/server-only/team/delete-team.ts | 1 + 1 file changed, 1 insertion(+) 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: {}, From a70b0702c3ac765b0126611f55bf2dc3913fe5b9 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 29 Jun 2026 14:50:35 +1000 Subject: [PATCH 11/12] fix: add missing teams branding guard (#3049) --- .../t.$teamUrl+/settings.branding.tsx | 83 ++++++++++++------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx index 287a91b8b..b674dc460 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.branding.tsx @@ -1,14 +1,17 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { putFile } from '@documenso/lib/universal/upload/put-file'; +import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css'; import { trpc } from '@documenso/trpc/react'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { plural } from '@lingui/core/macro'; import { Trans, useLingui } from '@lingui/react/macro'; import { Loader } from 'lucide-react'; import { useState } from 'react'; +import { Link } from 'react-router'; import { BrandingPreferencesForm, @@ -36,6 +39,8 @@ export default function TeamsSettingsPage() { const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation(); + const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED(); + const canCustomBranding = organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED(); @@ -112,39 +117,61 @@ export default function TeamsSettingsPage() { subtitle={t`Here you can set preferences and defaults for branding.`} /> -
- + {canConfigureBranding ? ( +
+ - {cssWarnings.length > 0 && ( - + {cssWarnings.length > 0 && ( + + + CSS rules were dropped during sanitisation + + + +
    + {cssWarnings.map((warning, index) => ( +
  • + {warning.detail} + {warning.line !== undefined && ( + + {' '} + (line {warning.line}) + + )} +
  • + ))} +
+
+
+ )} +
+ ) : ( + +
- CSS rules were dropped during sanitisation + Branding Preferences - -
    - {cssWarnings.map((warning, index) => ( -
  • - {warning.detail} - {warning.line !== undefined && ( - - {' '} - (line {warning.line}) - - )} -
  • - ))} -
+ + Currently branding can only be configured for Teams and above plans. - - )} -
+
+ + {canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && ( + + )} + + )}
); } From 9cdd2e7ff90e59aa6f7a0cd997e0b528d938a023 Mon Sep 17 00:00:00 2001 From: Martin Glaser <65476570+martindglaser@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:07:43 -0300 Subject: [PATCH 12/12] fix(email): render Preview inside Body across all email templates (#3004) --- packages/email/templates/access-auth-2fa.tsx | 3 ++- packages/email/templates/admin-user-created.tsx | 3 ++- packages/email/templates/bulk-send-complete.tsx | 5 ++++- packages/email/templates/confirm-email.tsx | 3 ++- packages/email/templates/confirm-team-email.tsx | 4 ++-- packages/email/templates/document-cancel.tsx | 3 ++- packages/email/templates/document-completed.tsx | 4 ++-- .../templates/document-created-from-direct-template.tsx | 4 ++-- packages/email/templates/document-invite.tsx | 3 ++- packages/email/templates/document-pending.tsx | 4 ++-- packages/email/templates/document-recipient-signed.tsx | 4 ++-- packages/email/templates/document-rejected.tsx | 3 ++- packages/email/templates/document-rejection-confirmed.tsx | 3 ++- packages/email/templates/document-reminder.tsx | 3 ++- packages/email/templates/document-self-signed.tsx | 4 ++-- packages/email/templates/document-super-delete.tsx | 3 ++- packages/email/templates/forgot-password.tsx | 3 ++- .../templates/organisation-account-link-confirmation.tsx | 3 ++- packages/email/templates/organisation-delete.tsx | 4 ++-- packages/email/templates/organisation-invite.tsx | 4 ++-- packages/email/templates/organisation-join.tsx | 4 ++-- packages/email/templates/organisation-leave.tsx | 4 ++-- packages/email/templates/organisation-limit-alert.tsx | 4 ++-- packages/email/templates/recipient-expired.tsx | 3 ++- packages/email/templates/recipient-removed-from-document.tsx | 3 ++- packages/email/templates/reset-password.tsx | 3 ++- packages/email/templates/team-delete.tsx | 4 ++-- packages/email/templates/team-email-removed.tsx | 4 ++-- 28 files changed, 58 insertions(+), 41 deletions(-) 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 277f28859..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)} +
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)} +