Compare commits

...

30 Commits

Author SHA1 Message Date
David Nguyen 496c1116f8 fix: remove presigned branding upload 2026-06-30 15:40:07 +10:00
Martin Glaser 9cdd2e7ff9 fix(email): render Preview inside Body across all email templates (#3004) 2026-06-29 16:07:43 +10:00
David Nguyen a70b0702c3 fix: add missing teams branding guard (#3049) 2026-06-29 14:50:35 +10:00
David Nguyen 1f170ef5e5 fix: scope organisation group deletion (#3047) 2026-06-29 14:11:31 +10:00
Lucas Smith 8f68393241 fix: tighten permission and validation checks (#3046) 2026-06-29 13:15:13 +10:00
Lucas Smith 381293af0c fix: invite email placeholder (#3045)
- **fix: interpolate inviterEmail in invite email mailto link**
- **fix: add alt attributes to email template images**
2026-06-28 22:01:20 +10:00
David Nguyen 97835b8dbb feat: add field multiselect (#3031) 2026-06-28 15:08:11 +10:00
David Nguyen 977d07330b fix: auto select field on drop (#3028) 2026-06-28 15:07:33 +10:00
Lucas Smith 037170f625 v2.14.0 2026-06-28 12:38:38 +10:00
Lucas Smith c219305eb1 chore: add tests for cert and audit log download via api (#3043) 2026-06-27 19:54:14 +10:00
David Nguyen 96ab78c33f fix: resolve permission issues (#3029) 2026-06-26 14:46:30 +10:00
Lucas Smith 241929bb97 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.
2026-06-26 00:01:55 +10:00
Lucas Smith 94adea149d 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.
2026-06-25 23:59:25 +10:00
David Nguyen 9c5eb43a26 fix: migrate emails to jobs (#3024) 2026-06-25 13:57:17 +10:00
Konrad e0ef11e8c3 chore(i18n): update Polish translation (#3020) 2026-06-25 13:24:40 +10:00
Lucas Smith 187b612568 chore: add translations (#3012) 2026-06-23 15:12:23 +10:00
Lucas Smith b37529a1cf fix: show warning on overlapping fields (#3017) 2026-06-23 15:11:57 +10:00
Lucas Smith 04f6e76178 feat: cap automated reminders before resend (#3016)
Stop sending automated reminders after a configurable threshold
(default 5) and reset the count on manual resend.
2026-06-23 15:11:52 +10:00
Lucas Smith f2525ae95b feat: add API endpoint to reject documents on behalf of recipients (#3007)
Programmatically record an external rejection on behalf of a recipient
who declined outside the platform. Flags the rejection as external in
the audit log, optionally attributes it to a specific team member via
actAsEmail, and enforces team membership and document visibility.
2026-06-22 21:59:07 +10:00
David Nguyen 2f24a8eab2 fix: set send status on resend (#3011) 2026-06-22 17:00:24 +10:00
David Nguyen d9b7722325 fix: correctly use default distribute envelope tab (#3010) 2026-06-22 16:27:50 +10:00
github-actions[bot] 783123f72b chore: extract translations (#2987) 2026-06-22 16:06:57 +10:00
Lucas Smith e8ed1c3d99 fix: respect branding enabled for recipient routes (#3009) 2026-06-22 16:06:06 +10:00
David Nguyen c23d739f76 feat: allow additional envelope duplicate settings (#3008) 2026-06-22 14:41:38 +10:00
Lucas Smith 0bf58ca66e feat: add custom brand colours to emails (#3005) 2026-06-22 14:33:34 +10:00
David Nguyen dee3259088 fix: remove old dialogs (#3006) 2026-06-22 14:17:22 +10:00
Nandini Dhanrale 6ad1a2dfaf fix: signing request email renders blank when organisation/team branding is enabled (#2968) 2026-06-22 14:15:12 +10:00
Abdelrahman Abdelhamed 306e7fe5ed fix: render unicode characters in typed signatures (#2728) 2026-06-22 13:40:56 +10:00
Yash Singh 219db32fdf fix: only send S3 checksums when required to support S3-compatible storage (#2984) 2026-06-22 13:35:37 +10:00
David Nguyen 948d1bbf12 fix: improve team member removal ux (#3001) 2026-06-22 12:16:55 +10:00
252 changed files with 11035 additions and 3485 deletions
+1 -1
View File
@@ -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"
+1 -2
View File
@@ -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
+11
View File
@@ -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.
@@ -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
-11
View File
@@ -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.'
+11
View File
@@ -1,3 +1,14 @@
<!--
We are no longer accepting external pull requests.
Aside from a small group of trusted contributors we reach out to directly,
new external PRs will usually be closed with a request to open an issue instead.
This is a security decision. See https://documenso.com/blog/why-we-re-pausing-external-pull-requests
If you're a trusted contributor or maintainer, continue below.
Otherwise, please open a detailed issue: https://github.com/documenso/documenso/issues/new/choose
-->
## Description
<!--- Describe the changes introduced by this pull request. -->
@@ -1,40 +0,0 @@
---
name: Test Addition
about: Submit a new test, either unit or end-to-end (E2E), for review and inclusion
---
## Description
<!--- Provide a clear and concise description of the new test you are adding. -->
<!--- Explain the purpose of the test and what it aims to validate. -->
## Related Issue
<!--- If this test addition is related to a specific issue, reference it here using #issue_number. -->
<!--- For example, "Fixes #123" or "Addresses #456". -->
## Test Details
<!--- Describe the details of the test you're adding. -->
<!--- Include information about inputs, expected outputs, and any specific scenarios. -->
- 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
<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->
- [ ] 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
<!--- Provide any additional context or notes for the reviewers. -->
<!--- This might include explanations about the testing approach or any potential concerns. -->
+1 -7
View File
@@ -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! 💚🚀
<br /> 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!
<br /> One of our team members will review it and get back to you as soon as it possible 💚
<br /> One of our team members will review it and get back to you as soon as possible 💚
<br /> Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)
@@ -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 }}`,
}
});
}
-63
View File
@@ -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 }}`,
}
});
}
@@ -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! 💚🚀
+1 -1
View File
@@ -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'
+1 -2
View File
@@ -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
+11 -4
View File
@@ -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
Canonical: https://documenso.com/.well-known/security.txt
+20 -6
View File
@@ -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
+22 -12
View File
@@ -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
<a href=""><img src="" alt=""></a>
</p>
- [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
<!-- - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. -->
@@ -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).
+38
View File
@@ -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.
+6 -64
View File
@@ -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.
+9 -38
View File
@@ -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.
@@ -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
<div className="mt-16 flex items-center justify-center gap-4">
@@ -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
@@ -86,6 +86,21 @@ Callback URL: `https://<your-domain>/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
@@ -235,7 +235,7 @@ spec:
```
<Callout type="info">
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:<version>`) in production instead of `latest`
to ensure predictable deployments.
</Callout>
@@ -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)
@@ -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
@@ -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 |
---
@@ -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:<version>
# Risky — may pull breaking changes
docker pull documenso/documenso:latest
@@ -27,6 +27,14 @@ import { Callout } from 'fumadocs-ui/components/callout';
Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding.
</Callout>
<Callout type="warn">
**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.
</Callout>
---
## Deployment Options
@@ -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:<version>
```
Replace `1.6.0` with your target version.
Replace `<version>` with your target version.
</Step>
<Step>
@@ -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:<version>
```
</Step>
@@ -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:<version>
```
Or if using environment variable substitution:
```bash
# In .env
DOCUMENSO_VERSION=1.6.0
DOCUMENSO_VERSION=<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:<version> \
-n documenso
```
@@ -295,7 +295,7 @@ spec:
spec:
containers:
- name: documenso
image: documenso/documenso:1.6.0
image: documenso/documenso:<version>
```
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:<version>
# Run migrations only
docker run --rm \
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@host:5432/documenso" \
documenso/documenso:1.6.0 \
documenso/documenso:<version> \
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:<previous-version>
```
</Tab>
-22
View File
@@ -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"]
+7 -93
View File
@@ -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.
@@ -1,243 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
export type DocumentMoveToFolderDialogProps = {
documentId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveDocumentFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveDocumentFormSchema = z.infer<typeof ZMoveDocumentFormSchema>;
export const DocumentMoveToFolderDialog = ({
documentId,
open,
onOpenChange,
currentFolderId,
...props
}: DocumentMoveToFolderDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveDocumentFormSchema>({
resolver: zodResolver(ZMoveDocumentFormSchema),
defaultValues: {
folderId: currentFolderId,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId,
type: FolderType.DOCUMENT,
},
{
enabled: open,
},
);
const { mutateAsync: updateDocument } = trpc.document.update.useMutation();
useEffect(() => {
if (!open) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId });
}
}, [open, currentFolderId, form]);
const onSubmit = async (data: TMoveDocumentFormSchema) => {
try {
await updateDocument({
documentId,
data: {
folderId: data.folderId ?? null,
},
});
const documentsPath = formatDocumentsPath(team.url);
if (data.folderId) {
await navigate(`${documentsPath}/f/${data.folderId}`);
} else {
await navigate(documentsPath);
}
toast({
title: _(msg`Document moved`),
description: _(msg`The document has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the document to does not exist.`),
variant: 'destructive',
});
return;
}
if (error.code === AppErrorCode.UNAUTHORIZED) {
toast({
title: _(msg`Error`),
description: _(msg`You are not allowed to move this document.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the document.`),
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Document to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a folder to move this document to.</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
disabled={isFoldersLoading || form.formState.isSubmitting || currentFolderId === null}
>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,203 +0,0 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { AppError } from '@documenso/lib/errors/app-error';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SigningStatus, type Team, type User } from '@prisma/client';
import { History } from 'lucide-react';
import { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useCurrentTeam } from '~/providers/team';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
import { StackAvatar } from '../general/stack-avatar';
const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = {
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: TRecipientLite[];
team: Pick<Team, 'id' | 'url'> | null;
};
recipients: TRecipientLite[];
};
export const ZResendDocumentFormSchema = z.object({
recipients: z.array(z.number()).min(1, {
message: 'You must select at least one item.',
}),
});
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
const { user } = useSession();
const team = useCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === user.id;
const isCurrentTeamDocument = team && document.team?.url === team.url;
const isDisabled =
(!isOwner && !isCurrentTeamDocument) ||
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation();
const form = useForm<TResendDocumentFormSchema>({
resolver: zodResolver(ZResendDocumentFormSchema),
defaultValues: {
recipients: [],
},
});
const {
handleSubmit,
formState: { isSubmitting },
} = form;
const selectedRecipients = useWatch({
control: form.control,
name: 'recipients',
});
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients });
toast({
title: _(msg`Document re-sent`),
description: _(msg`Your document has been re-sent successfully.`),
duration: 5000,
});
setIsOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = getDistributeErrorMessage(error.code);
toast({
title: _(errorMessage.title),
description: _(errorMessage.description),
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle asChild>
<h1 className="text-center text-xl">
<Trans>Who do you want to remind?</Trans>
</h1>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem key={recipient.id} className="flex flex-row items-center justify-between gap-x-3">
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full border border-neutral-400"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
disabled={isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
className="flex-1"
loading={isSubmitting}
type="submit"
form={FORM_ID}
disabled={isSubmitting || selectedRecipients.length === 0}
>
<Trans>Send reminder</Trans>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -3,12 +3,13 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -32,7 +33,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { AlertTriangleIcon, InfoIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
@@ -138,6 +139,27 @@ export const EnvelopeDistributeDialog = ({
});
}, [recipientsWithIndex, envelope.authOptions]);
/**
* Whether any fields significantly overlap each other. This is surfaced as a
* non-blocking warning since overlapping fields still allow sending, but can
* complicate the signing process or cause fields to behave unexpectedly.
*/
const hasOverlappingEnvelopeFields = useMemo(
() =>
hasOverlappingFields(
envelope.fields.map((field) => ({
id: field.id,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
})),
),
[envelope.fields],
);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -206,6 +228,11 @@ export const EnvelopeDistributeDialog = ({
};
useEffect(() => {
// Default the distribution method tab to the envelope's configured setting.
if (isOpen && envelope.documentMeta) {
setValue('meta.distributionMethod', envelope.documentMeta.distributionMethod);
}
// Resync the whole envelope if the envelope is mid saving.
if (isOpen && (isAutosaving || autosaveError)) {
void handleSync();
@@ -235,6 +262,24 @@ export const EnvelopeDistributeDialog = ({
<Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}>
{hasOverlappingEnvelopeFields && (
<Alert variant="warning" className="mb-4 flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</Alert>
)}
<Tabs
onValueChange={(value) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -1,6 +1,7 @@
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
@@ -11,10 +12,12 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useCurrentTeam } from '~/providers/team';
@@ -37,6 +40,15 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const form = useForm({
defaultValues: {
includeRecipients: true,
includeFields: true,
},
});
const includeRecipients = form.watch('includeRecipients');
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
@@ -55,8 +67,14 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
});
const onDuplicate = async () => {
const { includeRecipients, includeFields } = form.getValues();
try {
await duplicateEnvelope({ envelopeId });
await duplicateEnvelope({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
});
} catch {
toast({
title: t`Something went wrong`,
@@ -70,7 +88,20 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
};
return (
<Dialog open={open} onOpenChange={(value) => !isDuplicating && setOpen(value)}>
<Dialog
open={open}
onOpenChange={(value) => {
if (isDuplicating) {
return;
}
setOpen(value);
if (!value) {
form.reset();
}
}}
>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
@@ -87,6 +118,49 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Controller
control={form.control}
name="includeRecipients"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeRecipients"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked === true);
if (!checked) {
form.setValue('includeFields', false);
}
}}
/>
<Label htmlFor="envelopeDuplicateIncludeRecipients">
<Trans>Include Recipients</Trans>
</Label>
</div>
)}
/>
<Controller
control={form.control}
name="includeFields"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeDuplicateIncludeFields"
checked={field.value}
disabled={!includeRecipients}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label htmlFor="envelopeDuplicateIncludeFields" className={!includeRecipients ? 'opacity-50' : ''}>
<Trans>Include Fields</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isDuplicating}>
@@ -25,14 +25,16 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
import { StackAvatar } from '../general/stack-avatar';
export type EnvelopeRedistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'> & {
recipients: TEnvelopeRecipientLite[];
};
envelopeType?: EnvelopeType;
trigger?: React.ReactNode;
};
@@ -44,7 +46,7 @@ export const ZEnvelopeRedistributeFormSchema = z.object({
export type TEnvelopeRedistributeFormSchema = z.infer<typeof ZEnvelopeRedistributeFormSchema>;
export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedistributeDialogProps) => {
export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: EnvelopeRedistributeDialogProps) => {
const recipients = envelope.recipients;
const { toast } = useToast();
@@ -70,9 +72,23 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist
try {
await redistributeEnvelope({ envelopeId: envelope.id, recipients });
const successMessage = match(envelopeType)
.with(EnvelopeType.DOCUMENT, () => ({
title: t`Document resent`,
description: t`Your document has been resent successfully.`,
}))
.with(EnvelopeType.TEMPLATE, () => ({
title: t`Template resent`,
description: t`Your template has been resent successfully.`,
}))
.otherwise(() => ({
title: t`Envelope resent`,
description: t`Your envelope has been resent successfully.`,
}));
toast({
title: t`Envelope resent`,
description: t`Your envelope has been resent successfully.`,
title: successMessage.title,
description: successMessage.description,
duration: 5000,
});
@@ -28,7 +28,7 @@ export type EnvelopesBulkMoveDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string;
onSuccess?: () => void;
onSuccess?: (folderId: string | null) => Promise<void> | void;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZBulkMoveFormSchema = z.object({
@@ -99,11 +99,12 @@ export const EnvelopesBulkMoveDialog = ({
await trpcUtils.template.findTemplates.invalidate();
}
await onSuccess?.(data.folderId);
toast({
description: t`Selected items have been moved.`,
});
onSuccess?.();
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
@@ -16,6 +16,17 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { match } from 'ts-pattern';
/**
* The reason a team member cannot be removed from the team. When set, the delete
* dialog explains the reason instead of offering a confirm button.
*/
export type TeamMemberDeleteDisableReason =
| 'TEAM_OWNER'
| 'HIGHER_ROLE'
| 'INHERIT_MEMBER_ENABLED'
| 'INHERITED_MEMBER';
export type TeamMemberDeleteDialogProps = {
teamId: number;
@@ -23,7 +34,7 @@ export type TeamMemberDeleteDialogProps = {
memberId: string;
memberName: string;
memberEmail: string;
isInheritMemberEnabled: boolean | null;
disableReason?: TeamMemberDeleteDisableReason | null;
trigger?: React.ReactNode;
};
@@ -34,7 +45,7 @@ export const TeamMemberDeleteDialog = ({
memberId,
memberName,
memberEmail,
isInheritMemberEnabled,
disableReason,
}: TeamMemberDeleteDialogProps) => {
const [open, setOpen] = useState(false);
@@ -86,10 +97,19 @@ export const TeamMemberDeleteDialog = ({
</DialogDescription>
</DialogHeader>
{isInheritMemberEnabled ? (
{disableReason ? (
<Alert variant="neutral">
<AlertDescription>
<Trans>You cannot remove members from this team if the inherit member feature is enabled.</Trans>
{match(disableReason)
.with('TEAM_OWNER', () => <Trans>You cannot remove the organisation owner from the team.</Trans>)
.with('HIGHER_ROLE', () => <Trans>You cannot remove a member with a role higher than your own.</Trans>)
.with('INHERIT_MEMBER_ENABLED', () => (
<Trans>You cannot remove members from this team while the inherit member feature is enabled.</Trans>
))
.with('INHERITED_MEMBER', () => (
<Trans>This member is inherited from a group and cannot be removed from the team directly.</Trans>
))
.exhaustive()}
</AlertDescription>
</Alert>
) : (
@@ -109,11 +129,10 @@ export const TeamMemberDeleteDialog = ({
<Trans>Close</Trans>
</Button>
{!isInheritMemberEnabled && (
{!disableReason && (
<Button
type="submit"
variant="destructive"
disabled={Boolean(isInheritMemberEnabled)}
loading={isDeletingTeamMember}
onClick={async () => deleteTeamMember({ teamId, memberId })}
>
@@ -1,232 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
export type TemplateMoveToFolderDialogProps = {
templateId: number;
templateTitle: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
currentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
const ZMoveTemplateFormSchema = z.object({
folderId: z.string().nullable().optional(),
});
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
export function TemplateMoveToFolderDialog({
templateId,
templateTitle,
isOpen,
onOpenChange,
currentFolderId,
...props
}: TemplateMoveToFolderDialogProps) {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useCurrentTeam();
const [searchTerm, setSearchTerm] = useState('');
const form = useForm<TMoveTemplateFormSchema>({
resolver: zodResolver(ZMoveTemplateFormSchema),
defaultValues: {
folderId: currentFolderId ?? null,
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,
},
{
enabled: isOpen,
},
);
const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation();
useEffect(() => {
if (!isOpen) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId ?? null });
}
}, [isOpen, currentFolderId, form]);
const onSubmit = async (data: TMoveTemplateFormSchema) => {
try {
await updateTemplate({
templateId,
data: {
folderId: data.folderId ?? null,
},
});
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been moved successfully.`),
variant: 'default',
});
onOpenChange(false);
const templatesPath = formatTemplatesPath(team.url);
if (data.folderId) {
void navigate(`${templatesPath}/f/${data.folderId}`);
} else {
void navigate(templatesPath);
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
toast({
title: _(msg`Error`),
description: _(msg`The folder you are trying to move the template to does not exist.`),
variant: 'destructive',
});
return;
}
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while moving the template.`),
variant: 'destructive',
});
}
};
const filteredFolders = folders?.data?.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Folder</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Move &quot;{templateTitle}&quot; to a folder</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
<FormField
control={form.control}
name="folderId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Folder</Trans>
</FormLabel>
<FormControl>
<div className="max-h-96 space-y-2 overflow-y-auto">
{isFoldersLoading ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : (
<>
<Button
type="button"
variant={field.value === null ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(null)}
disabled={currentFolderId === null}
>
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home (No Folder)</Trans>
</Button>
{filteredFolders?.map((folder) => (
<Button
key={folder.id}
type="button"
variant={field.value === folder.id ? 'default' : 'outline'}
className="w-full justify-start"
onClick={() => field.onChange(folder.id)}
disabled={currentFolderId === folder.id}
>
<FolderIcon className="mr-2 h-4 w-4" />
{folder.name}
</Button>
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
</>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
<Trans>Move</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -1,5 +1,10 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
BRANDING_LOGO_ALLOWED_TYPES,
BRANDING_LOGO_MAX_SIZE_BYTES,
BRANDING_LOGO_MAX_SIZE_MB,
} from '@documenso/lib/constants/branding';
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { cn } from '@documenso/ui/lib/utils';
@@ -21,15 +26,15 @@ import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const ZBrandingPreferencesFormSchema = z.object({
brandingEnabled: z.boolean().nullable(),
brandingLogo: z
.instanceof(File)
.refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB')
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.refine(
(file) => file.size <= BRANDING_LOGO_MAX_SIZE_BYTES,
`File size must be less than ${BRANDING_LOGO_MAX_SIZE_MB}MB`,
)
.refine((file) => BRANDING_LOGO_ALLOWED_TYPES.includes(file.type), 'Only .jpg, .png, and .webp files are accepted')
.nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(),
@@ -199,7 +204,7 @@ export function BrandingPreferencesForm({
<FormControl className="relative">
<Input
type="file"
accept={ACCEPTED_FILE_TYPES.join(',')}
accept={BRANDING_LOGO_ALLOWED_TYPES.join(',')}
disabled={!isBrandingEnabled}
onChange={(e) => {
const file = e.target.files?.[0];
@@ -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) => (
<a
key={attachment.id}
href={attachment.data}
href={toSafeHref(attachment.data)}
title={attachment.data}
target="_blank"
rel="noopener noreferrer"
@@ -1,5 +1,6 @@
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -24,7 +25,7 @@ export type DocumentAttachmentsPopoverProps = {
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
@@ -156,7 +157,7 @@ export const DocumentAttachmentsPopover = ({
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={attachment.data}
href={toSafeHref(attachment.data)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
@@ -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' });
@@ -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' });
@@ -19,6 +19,7 @@ import {
Download,
Edit,
FileOutputIcon,
History,
Loader,
MoreHorizontal,
Pencil,
@@ -29,10 +30,10 @@ import {
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeRenameDialog } from '~/components/dialogs/envelope-rename-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
@@ -67,8 +68,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
const documentsPath = formatDocumentsPath(team.url);
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger data-testid="document-page-view-action-btn">
@@ -172,13 +171,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
/>
)}
<DocumentResendDialog
document={{
...envelope,
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
}}
recipients={nonSignedRecipients}
/>
{canManageDocument && (
<EnvelopeRedistributeDialog
envelope={envelope}
envelopeType={EnvelopeType.DOCUMENT}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<DocumentShareButton
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
@@ -1,5 +1,6 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { nanoid } from '@documenso/lib/universal/id';
import { isHttpUrl, toSafeHref } from '@documenso/lib/utils/is-http-url';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
@@ -22,7 +23,7 @@ export type EmbeddedEditorAttachmentPopoverProps = {
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
url: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
@@ -117,7 +118,7 @@ export const EmbeddedEditorAttachmentPopover = ({
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={attachment.data}
href={toSafeHref(attachment.data)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
@@ -1,3 +1,4 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
@@ -13,6 +14,7 @@ import {
} from '@documenso/lib/universal/field-renderer/field-renderer';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import {
Command,
@@ -47,6 +49,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(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,
@@ -62,6 +71,36 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
);
/**
* Debounce the fields used for overlap highlighting so we don't recompute on every
* small drag/resize tick. Overlaps only occur within the same page and envelope
* item, so computing from this page's fields alone is sufficient.
*/
const debouncedPageFields = useDebouncedValue(localPageFields, 300);
const overlappingFieldFormIds = useMemo(() => {
const formIds = new Set<string>();
const pairs = getOverlappingFieldPairs(
debouncedPageFields.map((field) => ({
id: field.formId,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
})),
);
for (const pair of pairs) {
formIds.add(pair.fieldA.id);
formIds.add(pair.fieldB.id);
}
return formIds;
}, [debouncedPageFields]);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
const isDragEvent = event.type === 'dragend';
@@ -113,6 +152,62 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
pageLayer.current?.batchDraw();
};
/**
* Draws (or removes) a dashed warning outline over a field that significantly
* overlaps another field. The highlight is a child of the field group so it moves
* and resizes with the field, and sits on top of the field's own rect (which is
* re-styled on every render and would otherwise clobber a direct stroke change).
*/
const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => {
const existingHighlight = fieldGroup.findOne('.field-overlap-highlight');
// Skip while a field is actively being dragged/resized. The highlight is driven
// by debounced field data, so it would lag behind and distort during the gesture.
// It is repainted once the gesture settles (the effect re-runs on isFieldChanging).
if (isFieldChanging) {
existingHighlight?.destroy();
return;
}
if (!isOverlapping) {
existingHighlight?.destroy();
return;
}
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const highlightAttrs = {
x: 0,
y: 0,
width: fieldRect.width(),
height: fieldRect.height(),
stroke: '#f59e0b',
strokeWidth: 2,
dash: [6, 4],
cornerRadius: 2,
strokeScaleEnabled: false,
listening: false,
} satisfies Partial<Konva.RectConfig>;
if (existingHighlight instanceof Konva.Rect) {
existingHighlight.setAttrs(highlightAttrs);
existingHighlight.moveToTop();
return;
}
const highlight = new Konva.Rect({
name: 'field-overlap-highlight',
...highlightAttrs,
});
fieldGroup.add(highlight);
highlight.moveToTop();
};
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
return;
@@ -139,6 +234,8 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
mode: 'edit',
});
syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId));
if (!isFieldEditable) {
return;
}
@@ -147,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();
});
@@ -355,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);
}
});
@@ -431,13 +519,48 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
setSelectedFields(liveSelectedFieldGroups);
}
// Mirror the editor's single selected field onto the canvas (Konva) selection.
//
// `addField` already marks a newly created field as the selected field, so this
// makes a field placed via the palette (drag-drop) or marquee creation show its
// resize handles immediately -- no second click needed. It also clears the canvas
// selection when the selected field is cleared (e.g. when the author starts
// placing another field), so the floating action toolbar can't intercept the next
// placement click. Runs after the render loop above so the field's group exists.
const selectedFormId = editorFields.selectedField?.formId ?? null;
const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1;
if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) {
const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId;
if (!isAlreadySelected) {
const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`);
if (fieldGroupToSelect instanceof Konva.Group) {
setSelectedFields([fieldGroupToSelect], { isAutoSelect: true });
}
}
} else if (selectedFormId === null && isSingleCanvasSelection) {
setSelectedFields([]);
}
// Rerender the transformer
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
}, [localPageFields, selectedKonvaFieldGroups]);
}, [
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()),
@@ -573,25 +696,30 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
return (
<>
{selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && (
<FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
handleChangeFieldType={changeSelectedFieldsType}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => 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 && (
<FieldActionButtons
handleDuplicateSelectedFields={duplicatedSelectedFields}
handleDuplicateSelectedFieldsOnAllPages={duplicatedSelectedFieldsOnAllPages}
handleDeleteSelectedFields={deletedSelectedFields}
handleChangeRecipient={changeSelectedFieldsRecipients}
handleChangeFieldType={changeSelectedFieldsType}
selectedFieldFormId={selectedKonvaFieldGroups.map((field) => 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 && (
<div
@@ -1,3 +1,4 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
@@ -17,6 +18,7 @@ import {
type TTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
@@ -28,7 +30,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
@@ -78,7 +80,7 @@ export const EnvelopeEditorFieldsPage = () => {
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { _ } = useLingui();
@@ -93,6 +95,53 @@ export const EnvelopeEditorFieldsPage = () => {
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
/**
* Debounce the fields used for overlap detection so we don't recompute on every
* small drag/resize movement, which is expensive on large field counts and can
* bog down lower-end devices.
*/
const debouncedLocalFields = useDebouncedValue(editorFields.localFields, 300);
/**
* Fields that significantly overlap each other. Overlapping fields render poorly in
* the editor and can behave unexpectedly during signing, so we warn the author here.
*/
const overlappingFieldPairs = useMemo(
() =>
getOverlappingFieldPairs(
debouncedLocalFields.map((field) => ({
id: field.formId,
envelopeItemId: field.envelopeItemId,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
})),
),
[debouncedLocalFields],
);
const handleReviewOverlappingField = () => {
const firstPair = overlappingFieldPairs[0];
if (!firstPair) {
return;
}
const targetField = editorFields.localFields.find((field) => field.formId === firstPair.fieldA.id);
if (!targetField) {
return;
}
if (targetField.envelopeItemId !== currentEnvelopeItem?.id) {
setCurrentEnvelopeItem(targetField.envelopeItemId);
}
editorFields.setSelectedField(targetField.formId);
};
const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => {
if (!selectedField) {
return;
@@ -211,6 +260,29 @@ export const EnvelopeEditorFieldsPage = () => {
</Alert>
)}
{overlappingFieldPairs.length > 0 && (
<Alert
variant="warning"
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
>
<div className="flex flex-row items-start gap-3">
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Overlapping fields detected</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Some fields are placed on top of each other. This may complicate the signing process or cause
fields to not work as expected.
</Trans>
</AlertDescription>
</div>
</div>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<EnvelopePdfViewer
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
@@ -25,6 +25,7 @@ import {
EyeIcon,
FileOutputIcon,
FolderInput,
History,
Loader,
MoreHorizontal,
Pencil,
@@ -35,10 +36,10 @@ import {
import { useState } from 'react';
import { Link } from 'react-router';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { EnvelopeCancelDialog } from '~/components/dialogs/envelope-cancel-dialog';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import { useCurrentTeam } from '~/providers/team';
@@ -95,8 +96,6 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
const documentsPath = formatDocumentsPath(team.url);
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger data-testid="document-table-action-btn">
@@ -244,7 +243,25 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
/>
)}
<DocumentResendDialog document={row} recipients={nonSignedRecipients} />
{canManageDocument && (
<EnvelopeRedistributeDialog
envelope={{
id: row.envelopeId,
status: row.status,
type: EnvelopeType.DOCUMENT,
recipients: row.recipients,
}}
envelopeType={EnvelopeType.DOCUMENT}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<History className="mr-2 h-4 w-4" />
<Trans>Resend</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
<DocumentShareButton
documentId={row.id}
@@ -29,7 +29,7 @@ export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
isLoading?: boolean;
isLoadingError?: boolean;
onMoveDocument?: (documentId: number) => void;
onMoveDocument?: (envelopeId: string) => void;
enableSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
@@ -117,7 +117,7 @@ export const DocumentsTable = ({
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown
row={row.original}
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.id) : undefined}
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.envelopeId) : undefined}
/>
</div>
),
@@ -29,7 +29,7 @@ import { useSearchParams } from 'react-router';
import { useCurrentTeam } from '~/providers/team';
import { TeamMemberDeleteDialog } from '../dialogs/team-member-delete-dialog';
import { TeamMemberDeleteDialog, type TeamMemberDeleteDisableReason } from '../dialogs/team-member-delete-dialog';
import { TeamMemberUpdateDialog } from '../dialogs/team-member-update-dialog';
import { TeamInheritMemberAlert } from '../general/teams/team-inherit-member-alert';
@@ -86,6 +86,39 @@ export const TeamMembersTable = () => {
);
const columns = useMemo(() => {
// A member is a direct team member when they belong to one of the team's
// INTERNAL_TEAM groups. Otherwise they are inherited from an organisation or
// custom group and cannot be managed directly from this team.
const isMemberPartOfInternalTeamGroup = (memberId: string) =>
groups.some(
(group) =>
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
group.members.some((member) => member.id === memberId),
);
// Determine why a member can't be removed from the team (if at all). The delete
// dialog uses this to explain the reason instead of attempting a removal that
// would fail.
const getDeleteDisableReason = (member: (typeof results)['data'][number]): TeamMemberDeleteDisableReason | null => {
if (organisation.ownerUserId === member.userId) {
return 'TEAM_OWNER';
}
if (!isTeamRoleWithinUserHierarchy(team.currentTeamRole, member.teamRole)) {
return 'HIGHER_ROLE';
}
if (memberAccessTeamGroup !== undefined) {
return 'INHERIT_MEMBER_ENABLED';
}
if (!isMemberPartOfInternalTeamGroup(member.id)) {
return 'INHERITED_MEMBER';
}
return null;
};
return [
{
header: _(msg`Team Member`),
@@ -111,15 +144,7 @@ export const TeamMembersTable = () => {
},
{
header: _(msg`Source`),
cell: ({ row }) => {
const internalTeamGroupFound = groups.find(
(group) =>
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
group.members.some((member) => member.id === row.original.id),
);
return internalTeamGroupFound ? _(msg`Member`) : _(msg`Group`);
},
cell: ({ row }) => (isMemberPartOfInternalTeamGroup(row.original.id) ? _(msg`Member`) : _(msg`Group`)),
},
{
header: _(msg`Actions`),
@@ -161,16 +186,9 @@ export const TeamMembersTable = () => {
memberId={row.original.id}
memberName={row.original.name ?? ''}
memberEmail={row.original.email}
isInheritMemberEnabled={memberAccessTeamGroup !== undefined}
disableReason={getDeleteDisableReason(row.original)}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
disabled={
organisation.ownerUserId === row.original.userId ||
!isTeamRoleWithinUserHierarchy(team.currentTeamRole, row.original.teamRole)
}
title={_(msg`Remove team member`)}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()} title={_(msg`Remove team member`)}>
<Trash2Icon className="mr-2 h-4 w-4" />
<Trans>Remove</Trans>
</DropdownMenuItem>
@@ -11,15 +11,15 @@ import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client';
import { Copy, Download, Edit, FolderIcon, MoreHorizontal, Pencil, Share2Icon, Trash2, Upload } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog';
import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog';
import { EnvelopesBulkMoveDialog } from '../dialogs/envelopes-bulk-move-dialog';
import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
export type TemplatesTableActionDropdownProps = {
row: {
@@ -44,6 +44,7 @@ export const TemplatesTableActionDropdown = ({
onDelete,
}: TemplatesTableActionDropdownProps) => {
const trpcUtils = trpcReact.useUtils();
const navigate = useNavigate();
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
@@ -153,12 +154,13 @@ export const TemplatesTableActionDropdown = ({
)}
</DropdownMenuContent>
<TemplateMoveToFolderDialog
templateId={row.id}
templateTitle={row.title}
isOpen={isMoveToFolderDialogOpen}
<EnvelopesBulkMoveDialog
envelopeIds={[row.envelopeId]}
envelopeType={EnvelopeType.TEMPLATE}
open={isMoveToFolderDialogOpen}
onOpenChange={setMoveToFolderDialogOpen}
currentFolderId={row.folderId}
currentFolderId={row.folderId ?? undefined}
onSuccess={(folderId) => navigate(folderId ? `${templateRootPath}/f/${folderId}` : templateRootPath)}
/>
<EnvelopeRenameDialog
@@ -1,7 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
import { trpc } from '@documenso/trpc/react';
@@ -49,26 +48,29 @@ export default function OrganisationSettingsBrandingPage() {
const { mutateAsync: updateOrganisationSettings } = trpc.organisation.settings.update.useMutation();
const { mutateAsync: updateOrganisationBrandingLogo } = trpc.organisation.settings.updateBrandingLogo.useMutation();
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
formData.append('payload', JSON.stringify({ organisationId: organisation.id }));
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateOrganisationBrandingLogo(formData);
}
const result = await updateOrganisationSettings({
organisationId: organisation.id,
data: {
brandingEnabled: brandingEnabled ?? undefined,
brandingLogo: uploadedBrandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
@@ -153,11 +153,11 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DocumentCertificateDownloadButton
className="mr-2"
documentId={document.id}
envelopeId={document.envelopeId}
documentStatus={document.status}
/>
<DocumentAuditLogDownloadButton documentId={document.id} />
<DocumentAuditLogDownloadButton envelopeId={document.envelopeId} />
</div>
</div>
</div>
@@ -16,10 +16,9 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import { z } from 'zod';
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
@@ -55,9 +54,12 @@ export default function DocumentsPage() {
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const documentsPath = formatDocumentsPath(team.url);
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
@@ -200,8 +202,8 @@ export default function DocumentsPage() {
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
onMoveDocument={(documentId) => {
setDocumentToMove(documentId);
onMoveDocument={(envelopeId) => {
setDocumentToMove(envelopeId);
setIsMovingDocument(true);
}}
enableSelection
@@ -213,8 +215,9 @@ export default function DocumentsPage() {
</div>
{documentToMove && (
<DocumentMoveToFolderDialog
documentId={documentToMove}
<EnvelopesBulkMoveDialog
envelopeIds={[documentToMove]}
envelopeType={EnvelopeType.DOCUMENT}
open={isMovingDocument}
currentFolderId={folderId}
onOpenChange={(open) => {
@@ -224,6 +227,9 @@ export default function DocumentsPage() {
setDocumentToMove(null);
}
}}
onSuccess={(destinationFolderId) =>
navigate(destinationFolderId ? `${documentsPath}/f/${destinationFolderId}` : documentsPath)
}
/>
)}
@@ -1,14 +1,16 @@
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,
@@ -35,6 +37,9 @@ export default function TeamsSettingsPage() {
});
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const { mutateAsync: updateTeamBrandingLogo } = trpc.team.settings.updateBrandingLogo.useMutation();
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
const canCustomBranding =
organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED();
@@ -43,22 +48,23 @@ export default function TeamsSettingsPage() {
try {
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
let uploadedBrandingLogo: string | undefined;
// Upload (or clear) the logo through the dedicated, server-validated route.
if (brandingLogo instanceof File || brandingLogo === null) {
const formData = new FormData();
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
formData.append('payload', JSON.stringify({ teamId: team.id }));
// Empty the branding logo if the user unsets it.
if (brandingLogo === null) {
uploadedBrandingLogo = '';
if (brandingLogo instanceof File) {
formData.append('brandingLogo', brandingLogo);
}
await updateTeamBrandingLogo(formData);
}
const result = await updateTeamSettings({
teamId: team.id,
data: {
brandingEnabled,
brandingLogo: uploadedBrandingLogo,
brandingUrl: brandingUrl || null,
brandingCompanyDetails: brandingCompanyDetails || null,
brandingColors,
@@ -112,39 +118,61 @@ export default function TeamsSettingsPage() {
subtitle={t`Here you can set preferences and defaults for branding.`}
/>
<section>
<BrandingPreferencesForm
canInherit={true}
hasAdvancedBranding={canCustomBranding}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
{canConfigureBranding ? (
<section>
<BrandingPreferencesForm
canInherit={true}
hasAdvancedBranding={canCustomBranding}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
{cssWarnings.length > 0 && (
<Alert variant="warning" className="mt-6">
{cssWarnings.length > 0 && (
<Alert variant="warning" className="mt-6">
<AlertTitle>
<Trans>CSS rules were dropped during sanitisation</Trans>
</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5">
{cssWarnings.map((warning, index) => (
<li key={index}>
{warning.detail}
{warning.line !== undefined && (
<span className="text-muted-foreground">
{' '}
<Trans>(line {warning.line})</Trans>
</span>
)}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
</section>
) : (
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>CSS rules were dropped during sanitisation</Trans>
<Trans>Branding Preferences</Trans>
</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-5">
{cssWarnings.map((warning, index) => (
<li key={index}>
{warning.detail}
{warning.line !== undefined && (
<span className="text-muted-foreground">
{' '}
<Trans>(line {warning.line})</Trans>
</span>
)}
</li>
))}
</ul>
<AlertDescription className="mr-2">
<Trans>Currently branding can only be configured for Teams and above plans.</Trans>
</AlertDescription>
</Alert>
)}
</section>
</div>
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<Button asChild variant="outline">
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Update Billing</Trans>
</Link>
</Button>
)}
</Alert>
)}
</div>
);
}
+1 -1
View File
@@ -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"
}
+11 -4
View File
@@ -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
Canonical: https://documenso.com/.well-known/security.txt
+212 -40
View File
@@ -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<HonoEnv>()
/**
* Download an envelope item by its ID.
@@ -30,24 +62,8 @@ export const downloadRoute = new Hono<HonoEnv>()
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<HonoEnv>()
}
},
)
/**
* Download the audit log for a document as a PDF.
* Requires API key authentication via Authorization header.
*/
.get(
'/envelope/:envelopeId/audit-log/download',
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/download',
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<HonoEnv>()
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<HonoEnv>()
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);
@@ -30,3 +30,17 @@ export const ZDownloadDocumentRequestParamsSchema = z.object({
});
export type TDownloadDocumentRequestParams = z.infer<typeof ZDownloadDocumentRequestParamsSchema>;
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<typeof ZDownloadEnvelopeAuditLogPdfRequestParamsSchema>;
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
>;
+1 -28
View File
@@ -1,9 +1,8 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { AppError } from '@documenso/lib/errors/app-error';
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
@@ -12,14 +11,11 @@ import { Hono } from 'hono';
import type { HonoEnv } from '../../router';
import { checkEnvelopeFileAccess, handleEnvelopeItemFileRequest, resolveFileUploadUserId } from './files.helpers';
import {
isAllowedUploadContentType,
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileRequestQuerySchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
import getEnvelopeItemPdfRoute from './routes/get-envelope-item-pdf';
@@ -61,29 +57,6 @@ export const filesRoute = new Hono<HonoEnv>()
return c.json({ error: 'Upload failed' }, 500);
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const userId = await resolveFileUploadUserId(c);
if (!userId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { fileName, contentType } = c.req.valid('json');
if (!isAllowedUploadContentType(contentType)) {
return c.json({ error: 'Unsupported content type' }, 400);
}
try {
const { key, url } = await getPresignPostUrl(fileName, contentType, userId);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
@@ -13,27 +13,6 @@ export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ALLOWED_UPLOAD_CONTENT_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] as const;
export const isAllowedUploadContentType = (contentType: string): boolean => {
const normalizedContentType = contentType.split(';').at(0)?.trim().toLowerCase();
return ALLOWED_UPLOAD_CONTENT_TYPES.some((allowed) => allowed === normalizedContentType);
};
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
});
export const ZGetPresignedPostUrlResponseSchema = z.object({
key: z.string().min(1),
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
-1
View File
@@ -105,7 +105,6 @@ app.route('/api/auth', auth);
// Files route.
app.use('/api/files/upload-pdf', fileRateLimitMiddleware);
app.use('/api/files/presigned-post-url', fileRateLimitMiddleware);
app.route('/api/files', filesRoute);
// AI route.
+3 -3
View File
@@ -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": "*",
+1 -2
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.13.0",
"version": "2.14.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -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",
@@ -0,0 +1,64 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
test.describe('Redistribute updates recipient send status', () => {
let user: User, team: Team, token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
}));
});
test('marks a NOT_SENT signer as SENT after a successful resend', async ({ request }) => {
const document = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const [recipient] = document.recipients;
// Simulate a recipient that is stuck at NOT_SENT on a pending document
// (e.g. the initial send did not dispatch an email for them).
await prisma.recipient.update({
where: { id: recipient.id },
data: {
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
sentAt: null,
},
});
const res = await request.post(`${baseUrl}/document/redistribute`, {
headers: { Authorization: `Bearer ${token}` },
data: {
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
recipients: [recipient.id],
},
});
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
const updatedRecipient = await prisma.recipient.findFirstOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.sendStatus).toBe(SendStatus.SENT);
expect(updatedRecipient.sentAt).not.toBeNull();
});
});
@@ -0,0 +1,260 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types';
import { type APIRequestContext, expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
const rejectRecipient = (
request: APIRequestContext,
authToken: string,
envelopeId: string,
recipientId: number,
reason: string,
actAsEmail?: string,
) => {
return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
recipientId,
reason,
actAsEmail,
} satisfies TRejectEnvelopeRecipientOnBehalfOfRequest,
});
};
test.describe('Reject recipient on behalf of', () => {
let user: User;
let team: Team;
let token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-reject-recipient',
expiresIn: null,
}));
});
test('should reject a recipient and record an external rejection audit log', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band');
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED);
expect(updatedRecipient.rejectionReason).toBe('Declined out of band');
const auditLog = await prisma.documentAuditLog.findFirst({
where: {
envelopeId: envelope.id,
type: 'DOCUMENT_RECIPIENT_REJECTED',
},
orderBy: { createdAt: 'desc' },
});
expect(auditLog).not.toBeNull();
const auditData = auditLog!.data as Record<string, unknown>;
expect(auditData.recipientId).toBe(recipient.id);
expect(auditData.recipientEmail).toBe(recipient.email);
expect(auditData.reason).toBe('Declined out of band');
expect(auditData.isExternal).toBe(true);
// No actAsEmail supplied - the rejection defaults to the API user.
expect(auditLog!.userId).toBe(user.id);
expect(auditLog!.email).toBe(user.email);
expect(auditData.onBehalfOfUserEmail).toBeUndefined();
});
test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => {
const member = await seedTeamMember({ teamId: team.id });
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const auditLog = await prisma.documentAuditLog.findFirstOrThrow({
where: {
envelopeId: envelope.id,
type: 'DOCUMENT_RECIPIENT_REJECTED',
},
orderBy: { createdAt: 'desc' },
});
// The audit log actor must be the elected member, not the API user.
expect(auditLog.userId).toBe(member.id);
expect(auditLog.email).toBe(member.email);
const auditData = auditLog.data as Record<string, unknown>;
expect(auditData.isExternal).toBe(true);
expect(auditData.onBehalfOfUserEmail).toBe(member.email);
});
test('should reject when actAsEmail is not a member of the team', async ({ request }) => {
// A user that exists but belongs to a different team.
const { user: outsider } = await seedUser();
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
const res = await rejectRecipient(
request,
token,
envelope.id,
recipient.id,
'Declined out of band',
outsider.email,
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
// Reject once - succeeds.
const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection');
expect(firstRes.ok()).toBeTruthy();
// Reject again - the recipient is no longer NOT_SIGNED.
const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection');
expect(secondRes.ok()).toBeFalsy();
expect(secondRes.status()).toBe(400);
// The original rejection reason must remain unchanged.
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.rejectionReason).toBe('First rejection');
});
test('should not allow rejecting a recipient in another team', async ({ request }) => {
// Seed a separate team/user that owns the document.
const { user: otherUser, team: otherTeam } = await seedUser();
const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']);
const recipient = envelope.recipients[0];
// Use the original team's token - it must not be able to reject.
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should return 404 for a non-existent recipient', async ({ request }) => {
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => {
const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']);
const recipient = targetEnvelope.recipients[0];
// Valid recipient ID, but paired with the wrong envelope ID.
const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The recipient must remain untouched.
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => {
// The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs.
const { team: visTeam, owner } = await seedTeam();
const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER });
const { token: managerToken } = await createApiToken({
userId: manager.id,
teamId: visTeam.id,
tokenName: 'manager-reject-token',
expiresIn: null,
});
// ADMIN-visibility document owned by the team owner.
const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], {
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
});
const recipient = envelope.recipients[0];
const res = await rejectRecipient(
request,
managerToken,
envelope.id,
recipient.id,
'Should be hidden by visibility',
);
// Visibility failure surfaces as not-found, matching the canonical checks.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
where: { id: recipient.id },
});
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(untouchedRecipient.rejectionReason).toBeNull();
});
});
@@ -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');
});
});
@@ -44,46 +44,6 @@ test.describe('File upload endpoint authorization', () => {
expect(res.status()).toBe(401);
});
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: { 'Content-Type': 'application/json' },
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer not-a-real-token',
},
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${presignToken}`,
},
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
});
// Authenticated, but the content type is not on the allow-list.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
@@ -0,0 +1,37 @@
import { optimiseBrandingLogo } from '@documenso/lib/utils/images/logo';
import { expect, test } from '@playwright/test';
import sharp from 'sharp';
const makePng = async (width = 1200, height = 1200) =>
sharp({
create: { width, height, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.png()
.toBuffer();
test.describe('optimiseBrandingLogo', () => {
test('re-encodes a valid image to a PNG buffer', async () => {
const input = await makePng();
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.format).toBe('png');
});
test('bounds the image to a maximum of 512px on its largest side', async () => {
const input = await makePng(2000, 1000);
const output = await optimiseBrandingLogo(input);
const metadata = await sharp(output).metadata();
expect(metadata.width).toBeLessThanOrEqual(512);
expect(metadata.height).toBeLessThanOrEqual(512);
});
test('rejects input that is not a valid image', async () => {
await expect(optimiseBrandingLogo(Buffer.from('this is not an image'))).rejects.toThrow();
});
});
@@ -0,0 +1,225 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from './fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
const LOGO_PATH = path.join(__dirname, '../../assets/logo.png');
type MultipartFile = { name: string; mimeType: string; buffer: Buffer };
const enableBrandingAndUpload = async (page: Page) => {
// Enable custom branding so the file input is no longer disabled.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
// Upload the logo file through the real multipart route.
await page.locator('input[type="file"]').setInputFiles(LOGO_PATH);
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
};
/**
* POST a logo straight to the dedicated multipart tRPC route using the
* authenticated browser cookies. This bypasses the client-side form validation,
* which is the only way to exercise the server-side image validation /
* sanitisation (`zfdBrandingImageFile` + `optimiseBrandingLogo`) and the entitlement gate.
*/
const postOrganisationBrandingLogo = async (page: Page, organisationId: string, file: MultipartFile | null) => {
const multipart: Record<string, string | MultipartFile> = {
payload: JSON.stringify({ organisationId }),
};
if (file) {
multipart.brandingLogo = file;
}
return await page
.context()
.request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/trpc/organisation.settings.updateBrandingLogo`, { multipart });
};
/**
* Grant the organisation the custom-branding entitlement. The positive branding
* flows require it whenever billing is enabled; with billing disabled the gate is
* bypassed, so this keeps these tests valid in both modes.
*/
const grantCustomBranding = async (organisationClaimId: string) => {
await prisma.organisationClaim.update({
where: { id: organisationClaimId },
data: { flags: { allowLegacyEnvelopes: true, allowCustomBranding: true } },
});
};
test('[BRANDING_LOGO]: uploads an organisation branding logo via the dedicated route', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
const parsed = JSON.parse(settings.brandingLogo);
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: uploads a team branding logo via the dedicated route', async ({ page }) => {
const { user, team, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// TeamGlobalSettings has no `teamId` column (the FK lives on Team), so read it
// through the team relation.
const teamWithSettings = await prisma.team.findUniqueOrThrow({
where: { id: team.id },
include: { teamGlobalSettings: true },
});
expect(teamWithSettings.teamGlobalSettings?.brandingLogo).toBeTruthy();
const parsed = JSON.parse(teamWithSettings.teamGlobalSettings?.brandingLogo ?? '');
expect(parsed).toHaveProperty('type');
expect(parsed).toHaveProperty('data');
});
test('[BRANDING_LOGO]: clears the organisation branding logo when the user removes it', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
await enableBrandingAndUpload(page);
// Confirm the logo was stored before we clear it.
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeTruthy();
// Remove the logo and save again.
await page.getByRole('button', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
// Clearing the logo persists an empty string via the dedicated route.
await expect
.poll(async () => {
const updated = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
return updated.brandingLogo;
})
.toBe('');
});
test('[BRANDING_LOGO]: validates and sanitises the logo on the server', async ({ page }) => {
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await grantCustomBranding(organisation.organisationClaim.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
// Positive control: a genuine PNG is accepted and stored. This also proves the
// direct multipart request shape matches what the route expects.
const validResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(validResponse.ok()).toBeTruthy();
const afterValid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(afterValid.brandingLogo).toBeTruthy();
// Bytes that pass the MIME/size allowlist but are not a real image must be
// rejected by the server (the `sharp` re-encode) without changing stored state.
const invalidResponse = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'fake.png',
mimeType: 'image/png',
buffer: Buffer.from('this is definitely not a valid png'),
});
expect(invalidResponse.ok()).toBeFalsy();
expect(invalidResponse.status()).toBeGreaterThanOrEqual(400);
expect(invalidResponse.status()).toBeLessThan(500);
const afterInvalid = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
// The previously stored, valid logo is left untouched by the rejected upload.
expect(afterInvalid.brandingLogo).toBe(afterValid.brandingLogo);
});
test('[BRANDING_LOGO]: rejects setting a logo without the custom-branding entitlement', async ({ page }) => {
// The entitlement is only enforced when billing is enabled; with billing off
// the check is intentionally skipped server-side, so this can't be exercised.
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true',
'Entitlement is only enforced when billing is enabled.',
);
// Seeded organisations have no `allowCustomBranding` claim flag.
const { user, organisation } = await seedUser({ isPersonalOrganisation: false });
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/branding`,
});
const response = await postOrganisationBrandingLogo(page, organisation.id, {
name: 'logo.png',
mimeType: 'image/png',
buffer: fs.readFileSync(LOGO_PATH),
});
expect(response.ok()).toBeFalsy();
const settings = await prisma.organisationGlobalSettings.findUniqueOrThrow({
where: { id: organisation.organisationGlobalSettingsId },
});
expect(settings.brandingLogo).toBeFalsy();
});
@@ -0,0 +1,89 @@
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
const requestMetadata = {
auth: null,
requestMetadata: {},
source: 'app' as const,
};
test.describe.configure({ mode: 'parallel' });
const canReadEnvelope = async (envelopeId: string, userId: number, teamId: number) => {
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);
});
@@ -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,<script>alert(1)</script>',
'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');
});
@@ -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);
});
@@ -3,6 +3,7 @@ import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
@@ -302,6 +303,95 @@ test.describe('document editor', () => {
expect(envelopes.length).toBeGreaterThanOrEqual(2);
});
test('duplicate document without recipients excludes recipients and fields', async ({ page }) => {
const { user, team } = await seedUser();
// Seed a draft document that has a recipient with a field.
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
key: `dup-exclude-recipients-${Date.now()}`,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Open the duplicate dialog.
await page.locator('button[title="Duplicate Envelope"]').click();
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Uncheck "Include Recipients" — this also disables and unchecks "Include Fields".
await page.getByLabel('Include Recipients').click();
await expect(page.getByLabel('Include Fields')).toBeDisabled();
// Duplicate.
await page.getByRole('button', { name: 'Duplicate' }).click();
await expectToastTextToBeVisible(page, 'Document Duplicated');
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// The duplicate should have neither recipients nor fields.
const duplicate = await prisma.envelope.findFirstOrThrow({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
id: { not: document.id },
},
include: { recipients: true, fields: true },
orderBy: { createdAt: 'desc' },
});
expect(duplicate.recipients).toHaveLength(0);
expect(duplicate.fields).toHaveLength(0);
});
test('duplicate document without fields keeps recipients but excludes fields', async ({ page }) => {
const { user, team } = await seedUser();
// Seed a draft document that has a recipient with a field.
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
key: `dup-exclude-fields-${Date.now()}`,
internalVersion: 2,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Open the duplicate dialog.
await page.locator('button[title="Duplicate Envelope"]').click();
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
// Uncheck only "Include Fields" (recipients stay included).
await page.getByLabel('Include Fields').click();
// Duplicate.
await page.getByRole('button', { name: 'Duplicate' }).click();
await expectToastTextToBeVisible(page, 'Document Duplicated');
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
// The duplicate should keep the recipient but have no fields.
const duplicate = await prisma.envelope.findFirstOrThrow({
where: {
teamId: team.id,
type: EnvelopeType.DOCUMENT,
id: { not: document.id },
},
include: { recipients: true, fields: true },
orderBy: { createdAt: 'desc' },
});
expect(duplicate.recipients).toHaveLength(1);
expect(duplicate.fields).toHaveLength(0);
});
test('download PDF dialog shows envelope items', async ({ page }) => {
await openDocumentEnvelopeEditor(page);
@@ -20,7 +20,7 @@ import {
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage } from '../fixtures/konva';
import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
type TFieldFlowResult = {
externalId: string;
@@ -46,6 +46,7 @@ const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: str
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
await surface.root.getByTestId('toast-close').click();
}
};
@@ -98,6 +99,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number
await canvas.click({ position, force: true });
};
/**
* Shift+click a field on the canvas to toggle it in/out of the current multi-selection.
*/
const shiftClickFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await root.waitForTimeout(300);
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
await canvas.click({ position, modifiers: ['Shift'], force: true });
};
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
const externalId = `e2e-fields-${nanoid()}`;
@@ -760,9 +772,106 @@ const assertChangeFieldTypePersistedInDatabase = async ({
expect(actualMetaTypes).toEqual(['date', 'date']);
};
// --- Shift+click multi-select flow ---
type TShiftClickFlowResult = {
externalId: string;
};
const SHIFT_CLICK_FIELD_POSITIONS = {
signature: { x: 150, y: 120 },
text: { x: 150, y: 260 },
name: { x: 150, y: 400 },
};
const runShiftClickMultiSelectFlow = async (surface: TEnvelopeEditorSurface): Promise<TShiftClickFlowResult> => {
const externalId = `e2e-shift-click-${nanoid()}`;
const root = surface.root;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
}
await updateExternalId(surface, externalId);
await setupRecipientsForFieldPlacement(surface);
await clickEnvelopeEditorStep(root, 'addFields');
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
// Place three fields, spaced far enough apart that their action toolbars don't
// overlap a neighbouring field's click target.
await placeFieldOnPdf(root, 'Signature', SHIFT_CLICK_FIELD_POSITIONS.signature);
await placeFieldOnPdf(root, 'Text', SHIFT_CLICK_FIELD_POSITIONS.text);
await placeFieldOnPdf(root, 'Name', SHIFT_CLICK_FIELD_POSITIONS.name);
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
// A plain click selects exactly one field.
await selectFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
// Shift+click a second field ADDS it to the selection (the new behaviour).
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.text);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
// Shift+click an already-selected field REMOVES it from the selection.
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
// Shift+click it again RE-ADDS it, leaving Signature + Text selected and Name excluded.
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
// Delete the two selected fields via the floating action toolbar. Only the
// un-selected Name field should remain -- proving the multi-selection contained
// exactly the two Shift-clicked fields.
await expect(root.locator('button[title="Remove"]')).toBeVisible();
await root.locator('button[title="Remove"]').click();
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
// Navigate away and back to verify persistence.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
return { externalId };
};
const assertShiftClickMultiSelectPersistedInDatabase = async ({
surface,
externalId,
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
orderBy: { createdAt: 'desc' },
include: { fields: true },
});
// Signature + Text were multi-selected via Shift+click and deleted; only Name remains.
expect(envelope.fields).toHaveLength(1);
expect(envelope.fields[0].type).toBe(FieldType.NAME);
};
// --- Test describe blocks ---
test.describe('document editor', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runShiftClickMultiSelectFlow(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
@@ -815,6 +924,16 @@ test.describe('document editor', () => {
});
test.describe('template editor', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runShiftClickMultiSelectFlow(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runAddAndPersistSignatureTextFields(surface);
@@ -867,6 +986,21 @@ test.describe('template editor', () => {
});
test.describe('embedded create', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-shift-click',
});
const result = await runShiftClickMultiSelectFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
@@ -944,6 +1078,22 @@ test.describe('embedded create', () => {
});
test.describe('embedded edit', () => {
test('shift+click adds and removes fields from the selection', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-shift-click',
});
const result = await runShiftClickMultiSelectFlow(surface);
await persistEmbeddedEnvelope(surface);
await assertShiftClickMultiSelectPersistedInDatabase({
surface,
...result,
});
});
test('add and persist signature/text fields', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
@@ -270,7 +270,7 @@ test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) =>
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
await expect(page.getByText('Document resent', { exact: true })).toBeVisible({
timeout: 10_000,
});
+32
View File
@@ -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 },
);
};
@@ -0,0 +1,406 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { OrganisationGroupType, type OrganisationMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
/**
* Calls a tRPC mutation directly using the cookies of whoever is currently
* signed in on the page context. This deliberately bypasses the UI: the
* authorisation checks under test live on the server, and the UI may simply
* hide a button rather than reject the request, which would mask a backend gap.
*/
const trpcMutation = async (page: Page, procedure: string, input: Record<string, unknown>) => {
return await page.request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({ json: input }),
});
};
const getOrganisationMember = async (userId: number, organisationId: string) => {
return await prisma.organisationMember.findFirstOrThrow({
where: {
userId,
organisationId,
},
});
};
const createCustomGroup = async (organisationId: string, organisationRole: OrganisationMemberRole) => {
return await prisma.organisationGroup.create({
data: {
id: generateDatabaseId('org_group'),
organisationId,
name: `custom-${organisationRole}-${nanoid()}`,
type: OrganisationGroupType.CUSTOM,
organisationRole,
},
});
};
const createPendingInvite = async (organisationId: string, organisationRole: OrganisationMemberRole) => {
return await prisma.organisationMemberInvite.create({
data: {
id: generateDatabaseId('member_invite'),
email: `invite-${nanoid()}@test.documenso.com`,
token: nanoid(32),
organisationId,
organisationRole,
},
});
};
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: member deletion', () => {
test('a manager cannot delete an admin via member.delete', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
const [managerUser, adminUser] = await seedOrganisationMembers({
members: [
{ name: 'Manager', organisationRole: 'MANAGER' },
{ name: 'Admin', organisationRole: 'ADMIN' },
],
organisationId: organisation.id,
});
const adminMember = await getOrganisationMember(adminUser.id, organisation.id);
await apiSignin({ page, email: managerUser.email });
const res = await trpcMutation(page, 'organisation.member.delete', {
organisationId: organisation.id,
organisationMemberId: adminMember.id,
});
expect(res.ok()).toBeFalsy();
// The admin must still be a member of the organisation.
const stillExists = await prisma.organisationMember.findFirst({
where: { id: adminMember.id },
});
expect(stillExists).not.toBeNull();
});
test('a manager cannot delete an admin via member.deleteMany', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
const [managerUser, adminUser] = await seedOrganisationMembers({
members: [
{ name: 'Manager', organisationRole: 'MANAGER' },
{ name: 'Admin', organisationRole: 'ADMIN' },
],
organisationId: organisation.id,
});
const adminMember = await getOrganisationMember(adminUser.id, organisation.id);
await apiSignin({ page, email: managerUser.email });
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
organisationId: organisation.id,
organisationMemberIds: [adminMember.id],
});
expect(res.ok()).toBeFalsy();
const stillExists = await prisma.organisationMember.findFirst({
where: { id: adminMember.id },
});
expect(stillExists).not.toBeNull();
});
test('a manager cannot delete the organisation owner', async ({ page }) => {
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
const [managerUser] = await seedOrganisationMembers({
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
organisationId: organisation.id,
});
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
await apiSignin({ page, email: managerUser.email });
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
organisationId: organisation.id,
organisationMemberIds: [ownerMember.id],
});
expect(res.ok()).toBeFalsy();
const stillExists = await prisma.organisationMember.findFirst({
where: { id: ownerMember.id },
});
expect(stillExists).not.toBeNull();
});
test('an admin cannot delete the organisation owner', async ({ page }) => {
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
const [adminUser] = await seedOrganisationMembers({
members: [{ name: 'Admin', organisationRole: 'ADMIN' }],
organisationId: organisation.id,
});
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
await apiSignin({ page, email: adminUser.email });
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
organisationId: organisation.id,
organisationMemberIds: [ownerMember.id],
});
expect(res.ok()).toBeFalsy();
const stillExists = await prisma.organisationMember.findFirst({
where: { id: ownerMember.id },
});
expect(stillExists).not.toBeNull();
});
test('a manager can still delete a regular member (positive control)', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
const [managerUser, memberUser] = await seedOrganisationMembers({
members: [
{ name: 'Manager', organisationRole: 'MANAGER' },
{ name: 'Member', organisationRole: 'MEMBER' },
],
organisationId: organisation.id,
});
const member = await getOrganisationMember(memberUser.id, organisation.id);
await apiSignin({ page, email: managerUser.email });
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
organisationId: organisation.id,
organisationMemberIds: [member.id],
});
expect(res.ok()).toBeTruthy();
const deleted = await prisma.organisationMember.findFirst({
where: { id: member.id },
});
expect(deleted).toBeNull();
});
});
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group deletion', () => {
test('a manager cannot delete an admin-role group', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
const [managerUser] = await seedOrganisationMembers({
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
organisationId: organisation.id,
});
const adminGroup = await createCustomGroup(organisation.id, 'ADMIN');
await apiSignin({ page, email: managerUser.email });
const res = await trpcMutation(page, 'organisation.group.delete', {
organisationId: organisation.id,
groupId: adminGroup.id,
});
expect(res.ok()).toBeFalsy();
const stillExists = await prisma.organisationGroup.findFirst({
where: { id: adminGroup.id },
});
expect(stillExists).not.toBeNull();
});
test('a manager can delete a member-role group (positive control)', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
const [managerUser] = await seedOrganisationMembers({
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
organisationId: organisation.id,
});
const memberGroup = await createCustomGroup(organisation.id, 'MEMBER');
await apiSignin({ page, email: managerUser.email });
const res = await trpcMutation(page, 'organisation.group.delete', {
organisationId: organisation.id,
groupId: memberGroup.id,
});
expect(res.ok()).toBeTruthy();
const deleted = await prisma.organisationGroup.findFirst({
where: { id: memberGroup.id },
});
expect(deleted).toBeNull();
});
});
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: invite resend', () => {
test('a manager cannot resend an admin-role invite', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
const [managerUser] = await seedOrganisationMembers({
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
organisationId: organisation.id,
});
const adminInvite = await createPendingInvite(organisation.id, 'ADMIN');
await apiSignin({ page, email: managerUser.email });
const res = await trpcMutation(page, 'organisation.member.invite.resend', {
organisationId: organisation.id,
invitationId: adminInvite.id,
});
expect(res.ok()).toBeFalsy();
});
test('a manager can resend a member-role invite (positive control)', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
const [managerUser] = await seedOrganisationMembers({
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
organisationId: organisation.id,
});
const memberInvite = await createPendingInvite(organisation.id, 'MEMBER');
await apiSignin({ page, email: managerUser.email });
const res = await trpcMutation(page, 'organisation.member.invite.resend', {
organisationId: organisation.id,
invitationId: memberInvite.id,
});
expect(res.ok()).toBeTruthy();
});
});
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', () => {
test('the owner cannot leave without transferring ownership first', async ({ page }) => {
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
await apiSignin({ page, email: ownerUser.email });
const res = await trpcMutation(page, 'organisation.leave', {
organisationId: organisation.id,
});
expect(res.ok()).toBeFalsy();
const stillExists = await prisma.organisationMember.findFirst({
where: { id: ownerMember.id },
});
expect(stillExists).not.toBeNull();
});
test('a non-owner member can still leave (positive control)', async ({ page }) => {
const { organisation } = await seedUser({ isPersonalOrganisation: false });
const [memberUser] = await seedOrganisationMembers({
members: [{ name: 'Member', organisationRole: 'MEMBER' }],
organisationId: organisation.id,
});
const member = await getOrganisationMember(memberUser.id, organisation.id);
await apiSignin({ page, email: memberUser.email });
const res = await trpcMutation(page, 'organisation.leave', {
organisationId: organisation.id,
});
expect(res.ok()).toBeTruthy();
const deleted = await prisma.organisationMember.findFirst({
where: { id: member.id },
});
expect(deleted).toBeNull();
});
});
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group membership scoping', () => {
test('cannot add a member from another organisation to a group', async ({ page }) => {
// Organisation A, where the actor is the owner/admin.
const { user: actor, organisation: organisationA } = await seedUser({
isPersonalOrganisation: false,
});
// A separate organisation B with a member the actor has no authority over.
const { organisation: organisationB } = await seedUser({ isPersonalOrganisation: false });
const [foreignUser] = await seedOrganisationMembers({
members: [{ name: 'Foreign', organisationRole: 'MEMBER' }],
organisationId: organisationB.id,
});
const foreignMember = await getOrganisationMember(foreignUser.id, organisationB.id);
// A custom group the actor legitimately controls in organisation A.
const groupA = await createCustomGroup(organisationA.id, 'MEMBER');
await apiSignin({ page, email: actor.email });
const res = await trpcMutation(page, 'organisation.group.update', {
id: groupA.id,
memberIds: [foreignMember.id],
});
expect(res.ok()).toBeFalsy();
const injectedMembership = await prisma.organisationGroupMember.findFirst({
where: { groupId: groupA.id, organisationMemberId: foreignMember.id },
});
expect(injectedMembership).toBeNull();
});
test('can add a member from the same organisation to a group (positive control)', async ({ page }) => {
const { user: actor, organisation } = await seedUser({ isPersonalOrganisation: false });
const [memberUser] = await seedOrganisationMembers({
members: [{ name: 'Member', organisationRole: 'MEMBER' }],
organisationId: organisation.id,
});
const member = await getOrganisationMember(memberUser.id, organisation.id);
const group = await createCustomGroup(organisation.id, 'MEMBER');
await apiSignin({ page, email: actor.email });
const res = await trpcMutation(page, 'organisation.group.update', {
id: group.id,
memberIds: [member.id],
});
expect(res.ok()).toBeTruthy();
const membership = await prisma.organisationGroupMember.findFirst({
where: { groupId: group.id, organisationMemberId: member.id },
});
expect(membership).not.toBeNull();
});
});
@@ -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);
});
@@ -142,3 +142,38 @@ test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Web
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
});
test('[SIGNING_BRANDING]: custom logo renders when branding is enabled and is hidden when disabled', async ({
page,
}) => {
const { user, team, organisation } = await seedUser();
await enableOrganisationBranding({
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
});
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['enabled-disabled-branding-signer@test.documenso.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: { internalVersion: 2 },
});
// Branding enabled → the custom logo is rendered on the signing page.
await page.goto(`/sign/${recipients[0].token}`);
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
// Disable branding while keeping the stored logo (the team inherits this).
await prisma.organisationGlobalSettings.update({
where: { id: organisation.organisationGlobalSettingsId },
data: { brandingEnabled: false },
});
// Branding disabled → the custom logo is gone and the Documenso fallback
// (an internal link to "/") is shown instead.
await page.goto(`/sign/${recipients[0].token}`);
await expect(page.getByRole('img', { name: `${team.name}'s Logo` })).toHaveCount(0);
await expect(page.locator('a[href="/"]').first()).toBeVisible();
});
@@ -238,7 +238,7 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expectToastTextToBeVisible(page, 'Document re-sent');
await expectToastTextToBeVisible(page, 'Document resent');
});
test('[TEAMS]: delete draft team document', async ({ page }) => {
@@ -0,0 +1,105 @@
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
/**
* Reproduces the "Team has no internal team groups" bug.
*
* When a team has member inheritance turned OFF, organisation admins/managers are
* still inherited into the team as team admins (shown with the "Group" source).
* These members are not part of the team's INTERNAL_TEAM group, so they cannot be
* removed via the team members page - attempting to do so threw a 500 ("Team has no
* internal team groups").
*
* Instead of crashing, the delete dialog must explain why the inherited member can't
* be removed and not offer a confirm button.
*/
test('[TEAMS]: explains why an inherited organisation member cannot be removed', async ({ page }) => {
// Team created with member inheritance OFF.
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
const inheritedAdminEmail = `inherited-admin-${team.url}@test.documenso.com`;
// A second organisation admin is inherited into the team as a team admin (source "Group").
await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{
name: 'Inherited Admin',
email: inheritedAdminEmail,
organisationRole: OrganisationMemberRole.ADMIN,
},
],
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/members`,
});
const inheritedMemberRow = page.getByRole('row').filter({ hasText: inheritedAdminEmail });
// Sanity check: the member is inherited from a group, not a direct team member.
await expect(inheritedMemberRow).toBeVisible();
await expect(inheritedMemberRow).toContainText('Group');
await openDropdownMenu(page, inheritedMemberRow.getByRole('button').last());
// The action stays enabled - opening it shows a dialog explaining why the inherited
// member can't be removed, rather than triggering the 500.
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
await expect(removeMenuItem).toBeEnabled();
await removeMenuItem.click();
await expect(page.getByText('inherited from a group').first()).toBeVisible();
// No confirm button is offered, so the broken removal can never be triggered.
await expect(page.getByRole('button', { name: 'Remove' })).toHaveCount(0);
});
/**
* Guards against over-disabling the remove action: a direct team member (one that
* belongs to the team's INTERNAL_TEAM group) must still be removable.
*/
test('[TEAMS]: can remove a direct team member', async ({ page }) => {
const { user: owner, team } = await seedUser({ inheritMembers: false });
const directMember = await seedTeamMember({
teamId: team.id,
name: 'Direct Member',
role: TeamMemberRole.MEMBER,
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/settings/members`,
});
const directMemberRow = page.getByRole('row').filter({ hasText: directMember.email });
await expect(directMemberRow).toBeVisible();
await openDropdownMenu(page, directMemberRow.getByRole('button').last());
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
// The "Remove" action is enabled for direct members and removing them succeeds.
await expect(removeMenuItem).toBeEnabled();
await removeMenuItem.click();
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByText('You have successfully removed this user from the team.').first()).toBeVisible();
// The member is actually gone after reloading the members list.
await page.reload();
await expect(page.getByRole('row').filter({ hasText: owner.email })).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: directMember.email })).toHaveCount(0);
});
@@ -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();
});
@@ -1,6 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { sendPendingEmail } from '@documenso/lib/server-only/document/send-pending-email';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
@@ -474,9 +473,12 @@ export const executeTspSign = async (opts: ExecuteTspSignOptions): Promise<Execu
});
if (pendingRecipients.length > 0) {
await sendPendingEmail({
id: { type: 'envelopeId', id: envelope.id },
recipientId: recipient.id,
await jobs.triggerJob({
name: 'send.document.pending.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
// TSP envelopes are forced SEQUENTIAL at send-time; this branch always
+3 -2
View File
@@ -12,12 +12,13 @@
"index.ts"
],
"scripts": {
"dev": "email dev --port 3002 --dir templates",
"dev": "react-router dev --config preview/vite.config.ts",
"preview:build": "react-router build --config preview/vite.config.ts",
"clean": "rimraf node_modules"
},
"dependencies": {
"@documenso/tailwind-config": "*",
"@documenso/nodemailer-resend": "4.0.0",
"@documenso/tailwind-config": "*",
"@react-email/body": "0.2.0",
"@react-email/button": "0.2.0",
"@react-email/code-block": "0.2.0",
+2
View File
@@ -0,0 +1,2 @@
/.react-router/
/build/
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
margin: 0;
padding: 0;
}
@@ -0,0 +1,337 @@
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/locales';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import type { FieldConfig } from '../lib/templates';
import { templates } from '../lib/templates';
import { viewports } from '../lib/viewports';
import { PropFields } from './prop-fields';
type Theme = 'light' | 'dark';
const GROUP_ORDER = ['Documents', 'Recipients', 'Organisations', 'Teams', 'Account', 'Admin'] as const;
const LANGUAGE_LABELS: Record<string, string> = {
en: 'English',
de: 'German',
fr: 'French',
es: 'Spanish',
it: 'Italian',
nl: 'Dutch',
pl: 'Polish',
'pt-BR': 'Portuguese (Brazil)',
ja: 'Japanese',
ko: 'Korean',
zh: 'Chinese',
};
const DEFAULT_COLORS = {
primary: '#a2e771',
primaryForeground: '#162c07',
background: '#ffffff',
foreground: '#0f172a',
};
type PlaygroundProps = {
slug: string;
fields: Record<string, FieldConfig>;
defaultProps: Record<string, unknown>;
};
export const EmailPlayground = ({ slug, fields, defaultProps }: PlaygroundProps) => {
const navigate = useNavigate();
const [props, setProps] = useState(defaultProps);
const [html, setHtml] = useState('');
const [loading, setLoading] = useState(false);
const [theme, setTheme] = useState<Theme>('light');
const [viewportIndex, setViewportIndex] = useState(2);
const [lang, setLang] = useState('en');
const [brandingEnabled, setBrandingEnabled] = useState(false);
const [colors, setColors] = useState(DEFAULT_COLORS);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const groupedTemplates = useMemo(() => {
const entries = Object.entries(templates);
return GROUP_ORDER.map((group) => ({
group,
entries: entries.filter(([, def]) => def.group === group),
})).filter((section) => section.entries.length > 0);
}, []);
const fetchHtml = useCallback(
async (currentProps: Record<string, unknown>, currentLang: string, brandColors: typeof colors | null) => {
setLoading(true);
try {
const response = await fetch('/api/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
props: currentProps,
lang: currentLang,
colors: brandColors,
assetBaseUrl: window.location.origin,
}),
});
if (response.ok) {
setHtml(await response.text());
}
} finally {
setLoading(false);
}
},
[slug],
);
// Reset props when navigating to a different template.
useEffect(() => {
setProps(defaultProps);
}, [defaultProps]);
// Re-render on any input change (debounced).
useEffect(() => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void fetchHtml(props, lang, brandingEnabled ? colors : null);
}, 250);
return () => clearTimeout(debounceRef.current);
}, [props, lang, brandingEnabled, colors, fetchHtml]);
const handlePropChange = (key: string, value: unknown) => {
setProps((prev) => ({ ...prev, [key]: value }));
};
const handleColorChange = (key: keyof typeof colors, value: string) => {
setColors((prev) => ({ ...prev, [key]: value }));
};
// Force dark mode inside the iframe by neutralising the prefers-color-scheme
// media query (color-scheme alone doesn't trigger it inside an iframe).
const displayHtml = theme === 'dark' && html ? html.replaceAll(/prefers-color-scheme:\s*dark/g, 'min-width:0') : html;
const viewport = viewports[viewportIndex];
return (
<div className="flex h-screen w-screen overflow-hidden bg-neutral-100 font-sans text-neutral-900">
{/* Sidebar */}
<aside className="flex h-full w-60 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white">
<div className="border-neutral-200 border-b px-4 py-3">
<h1 className="font-semibold text-sm">Email Preview</h1>
<p className="text-neutral-500 text-xs">{Object.keys(templates).length} templates</p>
</div>
<nav className="flex-1 px-2 py-2">
{groupedTemplates.map((section) => (
<div key={section.group} className="mb-3">
<div className="px-2 py-1 font-medium text-neutral-400 text-xs uppercase tracking-wide">
{section.group}
</div>
{section.entries.map(([id, def]) => (
<button
key={id}
type="button"
onClick={() => navigate(`/${id}`)}
className={`block w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
slug === id ? 'bg-neutral-900 text-white' : 'text-neutral-700 hover:bg-neutral-100'
}`}
>
{def.name}
</button>
))}
</div>
))}
</nav>
</aside>
{/* Props panel */}
<section className="flex h-full w-72 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white px-4 py-3">
<h2 className="mb-3 font-medium text-neutral-500 text-xs uppercase tracking-wide">Props</h2>
<PropFields fields={fields} values={props} onChange={handlePropChange} />
</section>
{/* Main */}
<main className="flex h-full flex-1 flex-col overflow-hidden">
<Toolbar
theme={theme}
setTheme={setTheme}
viewportIndex={viewportIndex}
setViewportIndex={setViewportIndex}
lang={lang}
setLang={setLang}
brandingEnabled={brandingEnabled}
setBrandingEnabled={setBrandingEnabled}
colors={colors}
onColorChange={handleColorChange}
loading={loading}
/>
<div
className={`flex flex-1 items-start justify-center overflow-auto p-6 ${
theme === 'dark' ? 'bg-neutral-800' : 'bg-neutral-200'
}`}
>
<div
className="flex-shrink-0 overflow-hidden rounded-lg bg-white shadow-lg"
style={{ width: viewport.width }}
>
<iframe
title={`${viewport.name} ${theme}`}
srcDoc={displayHtml}
className="h-[calc(100vh-8rem)] w-full border-0"
style={{ colorScheme: theme }}
/>
</div>
</div>
</main>
</div>
);
};
type ToolbarProps = {
theme: Theme;
setTheme: (theme: Theme) => void;
viewportIndex: number;
setViewportIndex: (index: number) => void;
lang: string;
setLang: (lang: string) => void;
brandingEnabled: boolean;
setBrandingEnabled: (enabled: boolean) => void;
colors: typeof DEFAULT_COLORS;
onColorChange: (key: keyof typeof DEFAULT_COLORS, value: string) => void;
loading: boolean;
};
const Toolbar = (props: ToolbarProps) => {
return (
<div className="flex flex-wrap items-center gap-4 border-neutral-200 border-b bg-white px-4 py-2">
<SegmentedControl
label="Theme"
value={props.theme}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]}
onChange={(value) => props.setTheme(value as Theme)}
/>
<SegmentedControl
label="Viewport"
value={String(props.viewportIndex)}
options={viewports.map((viewport, index) => ({ value: String(index), label: viewport.name }))}
onChange={(value) => props.setViewportIndex(Number(value))}
/>
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
<span className="font-medium">Language</span>
<select
value={props.lang}
onChange={(event) => props.setLang(event.target.value)}
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs"
>
{SUPPORTED_LANGUAGE_CODES.map((code) => (
<option key={code} value={code}>
{LANGUAGE_LABELS[code] ?? code}
</option>
))}
</select>
</label>
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
<input
type="checkbox"
checked={props.brandingEnabled}
onChange={(event) => props.setBrandingEnabled(event.target.checked)}
/>
<span className="font-medium">Brand colours</span>
</label>
{props.brandingEnabled && (
<div className="flex items-center gap-3">
<ColorInput
label="Primary"
value={props.colors.primary}
onChange={(value) => props.onColorChange('primary', value)}
/>
<ColorInput
label="On primary"
value={props.colors.primaryForeground}
onChange={(value) => props.onColorChange('primaryForeground', value)}
/>
<ColorInput
label="Background"
value={props.colors.background}
onChange={(value) => props.onColorChange('background', value)}
/>
<ColorInput
label="Text"
value={props.colors.foreground}
onChange={(value) => props.onColorChange('foreground', value)}
/>
</div>
)}
<span className="ml-auto text-neutral-400 text-xs">{props.loading ? 'Rendering…' : ''}</span>
</div>
);
};
type SegmentedControlProps = {
label: string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
};
const SegmentedControl = (props: SegmentedControlProps) => {
return (
<div className="flex items-center gap-1.5">
<span className="font-medium text-neutral-600 text-xs">{props.label}</span>
<div className="flex overflow-hidden rounded-md border border-neutral-300">
{props.options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => props.onChange(option.value)}
className={`px-2.5 py-1 text-xs transition-colors ${
props.value === option.value
? 'bg-neutral-900 text-white'
: 'bg-white text-neutral-700 hover:bg-neutral-100'
}`}
>
{option.label}
</button>
))}
</div>
</div>
);
};
type ColorInputProps = {
label: string;
value: string;
onChange: (value: string) => void;
};
const ColorInput = (props: ColorInputProps) => {
return (
<label className="flex items-center gap-1 text-neutral-600 text-xs">
<span>{props.label}</span>
<input
type="color"
value={props.value}
onChange={(event) => props.onChange(event.target.value)}
className="h-6 w-6 cursor-pointer rounded border border-neutral-300 bg-white p-0"
/>
</label>
);
};
@@ -0,0 +1,113 @@
import type { FieldConfig } from '../lib/templates';
type PropFieldsProps = {
fields: Record<string, FieldConfig>;
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
};
export const PropFields = ({ fields, values, onChange }: PropFieldsProps) => {
const entries = Object.entries(fields);
if (entries.length === 0) {
return <p className="text-neutral-400 text-xs">No editable props.</p>;
}
return (
<div className="grid gap-3">
{entries.map(([key, field]) => (
<PropField key={key} name={key} field={field} value={values[key]} onChange={(value) => onChange(key, value)} />
))}
</div>
);
};
type PropFieldProps = {
name: string;
field: FieldConfig;
value: unknown;
onChange: (value: unknown) => void;
};
const inputClass =
'w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs focus:border-neutral-500 focus:outline-none';
const PropField = ({ name, field, value, onChange }: PropFieldProps) => {
const id = `prop-${name}`;
return (
<div className="grid gap-1">
<label htmlFor={id} className="font-medium text-neutral-600 text-xs">
{field.label}
</label>
{field.type === 'text' && (
<input
id={id}
className={inputClass}
value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === 'textarea' && (
<textarea
id={id}
className={`${inputClass} min-h-16 resize-y font-mono`}
value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === 'number' && (
<input
id={id}
type="number"
className={inputClass}
value={value === undefined || value === null ? '' : String(value)}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value === '' ? undefined : Number(event.target.value))}
/>
)}
{field.type === 'boolean' && (
<input
id={id}
type="checkbox"
className="h-4 w-4"
checked={Boolean(value)}
onChange={(event) => onChange(event.target.checked)}
/>
)}
{field.type === 'list' && (
<textarea
id={id}
className={`${inputClass} min-h-16 resize-y font-mono`}
value={Array.isArray(value) ? value.join('\n') : ''}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value === '' ? [] : event.target.value.split('\n'))}
/>
)}
{field.type === 'select' && field.options && (
<select
id={id}
className={inputClass}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)}
{field.description && <p className="text-neutral-400 text-xs">{field.description}</p>}
</div>
);
};
@@ -0,0 +1,12 @@
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});
@@ -0,0 +1,56 @@
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
if (shellRendered) {
console.error(error);
}
},
});
setTimeout(abort, streamTimeout + 1000);
});
}
@@ -0,0 +1,407 @@
import type { ComponentType } from 'react';
import { AccessAuth2FAEmailTemplate } from '../../../templates/access-auth-2fa';
import { AdminUserCreatedTemplate } from '../../../templates/admin-user-created';
import { BulkSendCompleteEmail } from '../../../templates/bulk-send-complete';
import { ConfirmEmailTemplate } from '../../../templates/confirm-email';
import { ConfirmTeamEmailTemplate } from '../../../templates/confirm-team-email';
import { DocumentCancelTemplate } from '../../../templates/document-cancel';
import { DocumentCompletedEmailTemplate } from '../../../templates/document-completed';
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '../../../templates/document-created-from-direct-template';
import { DocumentInviteEmailTemplate } from '../../../templates/document-invite';
import { DocumentPendingEmailTemplate } from '../../../templates/document-pending';
import { DocumentRecipientSignedEmailTemplate } from '../../../templates/document-recipient-signed';
import { DocumentRejectedEmail } from '../../../templates/document-rejected';
import { DocumentRejectionConfirmedEmail } from '../../../templates/document-rejection-confirmed';
import { DocumentReminderEmailTemplate } from '../../../templates/document-reminder';
import { DocumentSelfSignedEmailTemplate } from '../../../templates/document-self-signed';
import { DocumentSuperDeleteEmailTemplate } from '../../../templates/document-super-delete';
import { ForgotPasswordTemplate } from '../../../templates/forgot-password';
import { OrganisationAccountLinkConfirmationTemplate } from '../../../templates/organisation-account-link-confirmation';
import { OrganisationDeleteEmailTemplate } from '../../../templates/organisation-delete';
import { OrganisationInviteEmailTemplate } from '../../../templates/organisation-invite';
import { OrganisationJoinEmailTemplate } from '../../../templates/organisation-join';
import { OrganisationLeaveEmailTemplate } from '../../../templates/organisation-leave';
import { OrganisationLimitAlertEmailTemplate } from '../../../templates/organisation-limit-alert';
import { RecipientExpiredTemplate } from '../../../templates/recipient-expired';
import { RecipientRemovedFromDocumentTemplate } from '../../../templates/recipient-removed-from-document';
import { ResetPasswordTemplate } from '../../../templates/reset-password';
import { TeamDeleteEmailTemplate } from '../../../templates/team-delete';
import { TeamEmailRemovedTemplate } from '../../../templates/team-email-removed';
export type FieldType = 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'list';
export type FieldConfig = {
type: FieldType;
label: string;
description?: string;
placeholder?: string;
default: unknown;
options?: { label: string; value: string }[];
};
export type TemplateDefinition = {
/** Human label for the sidebar. */
name: string;
/** Loose grouping for the sidebar. */
group: 'Documents' | 'Recipients' | 'Organisations' | 'Teams' | 'Account' | 'Admin';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ComponentType<any>;
/** Editable props surfaced in the preview UI. */
fields: Record<string, FieldConfig>;
};
// --- Reusable field presets ---
const documentNameField: FieldConfig = {
type: 'text',
label: 'Document name',
default: 'Open Source Pledge.pdf',
};
const recipientNameField: FieldConfig = {
type: 'text',
label: 'Recipient name',
default: 'Lucas Smith',
};
const roleField: FieldConfig = {
type: 'select',
label: 'Recipient role',
default: 'SIGNER',
options: [
{ label: 'Signer', value: 'SIGNER' },
{ label: 'Viewer', value: 'VIEWER' },
{ label: 'Approver', value: 'APPROVER' },
{ label: 'CC', value: 'CC' },
{ label: 'Assistant', value: 'ASSISTANT' },
],
};
/**
* Explicit template registry. Each entry maps a slug → component + editable
* `fields`. The slug is the route param (`/:slug`) and matches the source
* filename (sans extension).
*
* `fields` drives both the default preview values AND the editable inputs in
* the UI, so production templates stay free of preview-only defaults.
*/
export const templates: Record<string, TemplateDefinition> = {
// ---- Documents ----
'document-invite': {
name: 'Document invite',
group: 'Documents',
component: DocumentInviteEmailTemplate,
fields: {
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
inviterEmail: { type: 'text', label: 'Inviter email', default: 'lucas@documenso.com' },
documentName: documentNameField,
role: roleField,
customBody: {
type: 'textarea',
label: 'Custom message',
default: '',
description: 'Leave blank to use the default invite copy.',
},
},
},
'document-completed': {
name: 'Document completed',
group: 'Documents',
component: DocumentCompletedEmailTemplate,
fields: {
documentName: documentNameField,
customBody: { type: 'textarea', label: 'Custom message', default: '' },
},
},
'document-self-signed': {
name: 'Document self-signed',
group: 'Documents',
component: DocumentSelfSignedEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-pending': {
name: 'Document pending',
group: 'Documents',
component: DocumentPendingEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-reminder': {
name: 'Document reminder',
group: 'Documents',
component: DocumentReminderEmailTemplate,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
role: roleField,
customBody: { type: 'textarea', label: 'Custom message', default: '' },
},
},
'document-cancel': {
name: 'Document cancelled',
group: 'Documents',
component: DocumentCancelTemplate,
fields: {
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
documentName: documentNameField,
cancellationReason: {
type: 'textarea',
label: 'Cancellation reason',
default: '',
description: 'Optional. Blank renders no reason block.',
},
},
},
'document-rejected': {
name: 'Document rejected',
group: 'Documents',
component: DocumentRejectedEmail,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
documentUrl: { type: 'text', label: 'Document URL', default: 'https://documenso.com' },
rejectionReason: {
type: 'textarea',
label: 'Rejection reason',
default: 'The pledge amount is incorrect.',
description: 'Optional in production; blank renders no reason block.',
},
},
},
'document-rejection-confirmed': {
name: 'Document rejection confirmed',
group: 'Documents',
component: DocumentRejectionConfirmedEmail,
fields: {
recipientName: recipientNameField,
documentName: documentNameField,
documentOwnerName: { type: 'text', label: 'Document owner', default: 'Timur Ercan' },
reason: {
type: 'textarea',
label: 'Rejection reason',
default: 'The pledge amount is incorrect.',
description: 'Optional in production; blank renders no reason block.',
},
},
},
'document-created-from-direct-template': {
name: 'Document created (direct template)',
group: 'Documents',
component: DocumentCreatedFromDirectTemplateEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'document-super-delete': {
name: 'Document deleted (admin)',
group: 'Documents',
component: DocumentSuperDeleteEmailTemplate,
fields: {
documentName: documentNameField,
},
},
'bulk-send-complete': {
name: 'Bulk send complete',
group: 'Documents',
component: BulkSendCompleteEmail,
fields: {
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
templateName: { type: 'text', label: 'Template name', default: 'NDA Template' },
totalProcessed: { type: 'number', label: 'Total processed', default: 50 },
successCount: { type: 'number', label: 'Success count', default: 48 },
failedCount: { type: 'number', label: 'Failed count', default: 2 },
errors: {
type: 'list',
label: 'Errors',
default: ['Row 12: invalid email', 'Row 30: missing name'],
description: 'One error per line. Rendered when failed count > 0.',
},
},
},
// ---- Recipients ----
'document-recipient-signed': {
name: 'Recipient signed',
group: 'Recipients',
component: DocumentRecipientSignedEmailTemplate,
fields: {
documentName: documentNameField,
recipientName: recipientNameField,
},
},
'recipient-expired': {
name: 'Recipient expired',
group: 'Recipients',
component: RecipientExpiredTemplate,
fields: {
documentName: documentNameField,
recipientName: recipientNameField,
},
},
'recipient-removed-from-document': {
name: 'Recipient removed',
group: 'Recipients',
component: RecipientRemovedFromDocumentTemplate,
fields: {
documentName: documentNameField,
},
},
// ---- Organisations ----
'organisation-invite': {
name: 'Organisation invite',
group: 'Organisations',
component: OrganisationInviteEmailTemplate,
fields: {
senderName: { type: 'text', label: 'Sender name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-join': {
name: 'Organisation join',
group: 'Organisations',
component: OrganisationJoinEmailTemplate,
fields: {
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-leave': {
name: 'Organisation leave',
group: 'Organisations',
component: OrganisationLeaveEmailTemplate,
fields: {
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-delete': {
name: 'Organisation delete',
group: 'Organisations',
component: OrganisationDeleteEmailTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-limit-alert': {
name: 'Organisation limit alert',
group: 'Organisations',
component: OrganisationLimitAlertEmailTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
'organisation-account-link-confirmation': {
name: 'Account link confirmation',
group: 'Organisations',
component: OrganisationAccountLinkConfirmationTemplate,
fields: {
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
},
},
// ---- Teams ----
'confirm-team-email': {
name: 'Confirm team email',
group: 'Teams',
component: ConfirmTeamEmailTemplate,
fields: {
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
},
},
'team-delete': {
name: 'Team delete',
group: 'Teams',
component: TeamDeleteEmailTemplate,
fields: {},
},
'team-email-removed': {
name: 'Team email removed',
group: 'Teams',
component: TeamEmailRemovedTemplate,
fields: {
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
teamEmail: { type: 'text', label: 'Team email', default: 'team@documenso.com' },
},
},
// ---- Account ----
'confirm-email': {
name: 'Confirm email',
group: 'Account',
component: ConfirmEmailTemplate,
fields: {
confirmationLink: {
type: 'text',
label: 'Confirmation link',
default: 'https://documenso.com/confirm',
},
},
},
'forgot-password': {
name: 'Forgot password',
group: 'Account',
component: ForgotPasswordTemplate,
fields: {
resetPasswordLink: {
type: 'text',
label: 'Reset link',
default: 'https://documenso.com/reset',
},
},
},
'reset-password': {
name: 'Reset password',
group: 'Account',
component: ResetPasswordTemplate,
fields: {
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
},
},
'access-auth-2fa': {
name: 'Access auth 2FA',
group: 'Account',
component: AccessAuth2FAEmailTemplate,
fields: {
documentTitle: { type: 'text', label: 'Document title', default: 'Open Source Pledge.pdf' },
code: { type: 'text', label: 'Code', default: '123456' },
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
expiresInMinutes: { type: 'number', label: 'Expires in (min)', default: 10 },
},
},
// ---- Admin ----
'admin-user-created': {
name: 'Admin user created',
group: 'Admin',
component: AdminUserCreatedTemplate,
fields: {
resetPasswordLink: {
type: 'text',
label: 'Reset link',
default: 'https://documenso.com/reset',
},
},
},
};
export type TemplateId = keyof typeof templates;
/** Extract the default prop values from a template's field config. */
export const getDefaultProps = (fields: Record<string, FieldConfig>): Record<string, unknown> => {
const props: Record<string, unknown> = {};
for (const [key, field] of Object.entries(fields)) {
props[key] = field.default;
}
return props;
};
export const getTemplate = (slug: string): TemplateDefinition | undefined => templates[slug];
@@ -0,0 +1,10 @@
export type Viewport = {
name: string;
width: number;
};
export const viewports: Viewport[] = [
{ name: 'Mobile', width: 390 },
{ name: 'Tablet', width: 768 },
{ name: 'Desktop', width: 1024 },
];
+30
View File
@@ -0,0 +1,30 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
export const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
};
const App = () => {
return <Outlet />;
};
export default App;
+7
View File
@@ -0,0 +1,7 @@
import { index, type RouteConfig, route } from '@react-router/dev/routes';
export default [
index('routes/_index.tsx'),
route('api/render', 'routes/api.render.tsx'),
route(':slug', 'routes/$slug.tsx'),
] satisfies RouteConfig;
@@ -0,0 +1,35 @@
import { data } from 'react-router';
import { EmailPlayground } from '../components/playground';
import { getDefaultProps, getTemplate } from '../lib/templates';
import type { Route } from './+types/$slug';
export const loader = ({ params }: Route.LoaderArgs) => {
const { slug } = params;
const template = getTemplate(slug);
if (!template) {
throw data(`Unknown template: ${slug}`, { status: 404 });
}
return {
slug,
templateName: template.name,
fields: template.fields,
defaultProps: getDefaultProps(template.fields),
};
};
export const meta = ({ data: loaderData }: Route.MetaArgs) => {
if (!loaderData) {
return [{ title: 'Not found — Email Preview' }];
}
return [{ title: `${loaderData.templateName} — Email Preview` }];
};
const TemplatePage = ({ loaderData }: Route.ComponentProps) => {
return <EmailPlayground slug={loaderData.slug} fields={loaderData.fields} defaultProps={loaderData.defaultProps} />;
};
export default TemplatePage;
@@ -0,0 +1,13 @@
import { redirect } from 'react-router';
import { templates } from '../lib/templates';
/**
* The index has no UI of its own — redirect to the first template so the
* preview always opens on something.
*/
export const loader = () => {
const firstSlug = Object.keys(templates)[0];
return redirect(`/${firstSlug}`);
};
@@ -0,0 +1,61 @@
import { resolveEmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
import { renderEmailWithI18N } from '@documenso/lib/utils/render-email-with-i18n';
import { getTemplate } from '../lib/templates';
import type { Route } from './+types/api.render';
type RenderRequestBody = {
slug: string;
props: Record<string, unknown>;
lang?: string;
colors?: Record<string, string> | null;
assetBaseUrl: string;
};
/**
* POST /api/render — render an email template to HTML via the REAL production
* pipeline (`renderEmailWithI18N`), so i18n and brand-colour injection match a
* live send. Returns `text/html` for the client to drop into an iframe srcDoc.
*/
export const action = async ({ request }: Route.ActionArgs) => {
const body = (await request.json()) as RenderRequestBody;
const template = getTemplate(body.slug);
if (!template) {
return new Response(JSON.stringify({ error: `Unknown template: ${body.slug}` }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Resolve brand colours through the same resolver production uses, so the
// preview applies the same per-token fallbacks as a live send.
const brandingColors =
body.colors && Object.keys(body.colors).length > 0 ? resolveEmailBrandingColors(body.colors) : null;
const Component = template.component;
const element = <Component {...body.props} assetBaseUrl={body.assetBaseUrl} />;
const html = await renderEmailWithI18N(element, {
lang: body.lang ?? 'en',
branding: brandingColors
? {
brandingEnabled: true,
brandingUrl: '',
brandingLogo: '',
brandingCompanyDetails: '',
brandingHidePoweredBy: false,
brandingColors,
}
: undefined,
});
return new Response(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
};
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: { config: './tailwind.config.cjs' },
autoprefixer: {},
},
};

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