Compare commits

..

2 Commits

Author SHA1 Message Date
David Nguyen ec1741db8e fix: update 2026-06-25 10:35:32 +10:00
David Nguyen 93d868a389 docs: deprecate endpoints 2026-06-24 17:16:13 +10:00
250 changed files with 2192 additions and 4819 deletions
+1 -15
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 | resend | mailchannels
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
# OPTIONAL: Defines the host to use for sending emails.
NEXT_PRIVATE_SMTP_HOST="127.0.0.1"
@@ -180,20 +180,6 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=
NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=
# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org).
NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=
# OPTIONAL: Set to "true" to disable all signin methods (email, Google, Microsoft, OIDC).
NEXT_PUBLIC_DISABLE_SIGNIN=
# OPTIONAL: Set to "true" to disable email/password signin only. Also closes /forgot-password and /reset-password.
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=
# OPTIONAL: Set to "true" to hide the Google signin button.
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=
# OPTIONAL: Set to "true" to hide the Microsoft signin button.
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=
# OPTIONAL: Set to "true" to hide the OIDC signin button.
NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=
# OPTIONAL: When OIDC is the only enabled signin transport, /signin auto-redirects
# to the OIDC provider (rendering only a spinner). Set to "true" to disable this
# and keep showing the signin page.
NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=
# OPTIONAL: Set to true to use internal webapp url in browserless requests.
NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false
+2 -1
View File
@@ -34,7 +34,7 @@ body:
label: Browser [e.g., Chrome, Firefox]
- type: input
attributes:
label: Version [e.g., 2.13.0]
label: Version [e.g., 2.0.1]
- type: checkboxes
attributes:
label: Please check the boxes that apply to this issue report.
@@ -44,3 +44,4 @@ 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
@@ -1,11 +0,0 @@
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,3 +33,4 @@ 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,6 +15,17 @@ 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,14 +1,3 @@
<!--
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. -->
@@ -0,0 +1,40 @@
---
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. -->
+7 -1
View File
@@ -1,10 +1,13 @@
name: 'Welcome New Contributors'
on:
pull_request:
types: ['opened']
issues:
types: ['opened']
permissions:
pull-requests: write
issues: write
jobs:
@@ -17,7 +20,10 @@ 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 possible 💚
<br /> One of our team members will review it and get back to you as soon as it possible 💚
<br /> Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)
@@ -0,0 +1,62 @@
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
@@ -0,0 +1,63 @@
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,6 +16,24 @@ 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:
@@ -26,6 +44,8 @@ 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:
@@ -33,3 +53,10 @@ 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,status: assigned,status: triage'
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage'
+2 -1
View File
@@ -31,7 +31,8 @@ vscode:
extensions:
- aaron-bond.better-comments
- bradlc.vscode-tailwindcss
- biomejs.biome
- dbaeumer.vscode-eslint
- esbenp.prettier-vscode
- mikestead.dotenv
- unifiedjs.vscode-mdx
- GitHub.vscode-pull-request-github
+4 -11
View File
@@ -1,14 +1,7 @@
# 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
# General 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
+1 -1
View File
@@ -42,8 +42,8 @@ Documenso is an open-source document signing platform built as a **monorepo** us
| Package | Description | Port |
| -------------------------- | -------------------------------------------------------- | ---- |
| `@documenso/remix` | Main application - React Router (Remix) with Hono server | 3000 |
| `@documenso/documentation` | Documentation site (Next.js + Nextra) | 3002 |
| `@documenso/openpage-api` | Public analytics API | 3003 |
| `@documenso/docs` | Documentation site | 3004 |
### Core Packages (`packages/`)
+6 -20
View File
@@ -1,27 +1,13 @@
# Contributing to Documenso
> **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.
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.
## How to contribute now
## Before getting started
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.
- 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.
## English only PRs and Issues
+12 -22
View File
@@ -51,18 +51,16 @@ Join us in creating the next generation of open trust infrastructure.
## Community and Next Steps 🎯
- Try Documenso by self-hosting it or signing up at [documenso.com](https://documenso.com).
- Check out the first source code release in this repository and test it.
- 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 other community members.
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members.
- ⭐ the repository to help us raise awareness.
- Open detailed [issues](https://github.com/documenso/documenso/issues) to report bugs or propose features.
- 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.
## Contributing
> **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).
- To contribute, please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
## Contact us
@@ -83,21 +81,17 @@ 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
- [React Router v7](https://reactrouter.com/) - Framework
- [Hono](https://hono.dev/) - Server
- [Typescript](https://www.typescriptlang.org/) - Language
- [ReactRouter](https://reactrouter.com/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [Tailwind CSS](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) + [Radix UI](https://www.radix-ui.com/) - Component Library
- [Tailwind](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
- [react-email](https://react.email/) - Email Templates
- [Lingui](https://lingui.dev/) - Internationalization
- [tRPC](https://trpc.io/) - API
- [@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
- [@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
- [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. -->
@@ -202,10 +196,6 @@ 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
@@ -1,38 +0,0 @@
# 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.
+64 -6
View File
@@ -1,9 +1,67 @@
# Signing Certificate
# Creating your own signing certificate
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:
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:
- [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
1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key:
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.
`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)
+38 -9
View File
@@ -1,16 +1,45 @@
# @documenso/docs
# docs
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).
This is a Next.js application generated with
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
Content lives under `content/docs/` as MDX. See [WRITING_STYLE.md](../../WRITING_STYLE.md) for the documentation writing conventions.
Run development server:
```bash
# From the monorepo root
npm run dev --filter=@documenso/docs
npm run dev
# or
pnpm dev
# or
yarn dev
```
## Structure
Open http://localhost:3000 with your browser to see the result.
- `content/docs/`: Documentation pages (MDX).
- `lib/source.ts`: Content source adapter.
- `lib/layout.shared.tsx`: Shared layout options.
## 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
@@ -6,6 +6,8 @@ description: Create, manage, and send documents for signing via the API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -5,6 +5,8 @@ description: Complete reference for the Documenso REST API.
import { Callout } from 'fumadocs-ui/components/callout';
<EnvelopeWarning />
<Callout type="warn">
The guides below cover common API patterns but may not reflect the latest endpoints or parameters.
For an always up-to-date reference, see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -8,6 +8,7 @@
"teams",
"rate-limits",
"versioning",
"migrate-to-envelopes",
"developer-mode",
"common-errors"
]
@@ -0,0 +1,249 @@
---
title: Migrating to Envelopes
description: Why Documenso unified documents and templates into envelopes, and how to migrate from the deprecated document and template create endpoints.
---
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
## Summary
The following items have been deprecated and will be removed on the <strong>1st of March 2027</strong>:
- <strong>API V1</strong>
- <strong>A subset of SDK/API V2 endpoints</strong>
- <strong>Legacy documents and templates</strong>
- <strong>EmbedCreateDocumentV1</strong>
- <strong>EmbedCreateTemplateV1</strong>
- <strong>EmbedUpdateDocumentV1</strong>
- <strong>EmbedUpdateTemplateV1</strong>
The beta endpoint `/api/v2-beta` will also be removed. Use `/api/v2` instead, which is a drop-in replacement.
Nothing breaks before 1st of March 2027, so you can migrate at your own pace.
## What are legacy documents and templates
These are documents and templates created by the following endpoints:
- `POST /api/v2/document/create`
- `POST /api/v2/document/create/beta`
- `POST /api/v2/template/create`
- `POST /api/v2/template/create/beta`
- `POST /api/v1/documents`
- `POST /api/v1/templates`
- `POST /api/v1/templates/create-document`
- `POST /api/v1/templates/generate-document`
## What replaces legacy documents and templates
At the end of 2025 we introduced a unified system for documents and templates, called <strong>envelopes</strong>.
We still reference documents and templates throughout the documentation and application to distinguish them, but internally they are envelopes.
Moving to the envelope system gives you:
- **Multiple PDFs in one envelope.** Send several documents to sign in a single request.
- **One API for documents and templates.** Learn one set of endpoints instead of two misaligned ones.
- **A better editor and signing experience** for you and your recipients.
## How to migrate
{/* prettier-ignore */}
<Steps>
<Step>
### Switch to the envelope endpoints
Replace each deprecated endpoint with its `/api/v2/envelope/*` equivalent from the [mapping tables](#endpoint-mapping-reference) below.
</Step>
<Step>
### Set the envelope `type` on create
A single endpoint, `POST /api/v2/envelope/create`, can create both documents and templates. Set `type` to `DOCUMENT` or `TEMPLATE`. You can now upload more than one PDF using the `files` field.
</Step>
<Step>
### Update how you store IDs
Envelope IDs are **strings** (for example `envelope_abc123`), not numbers. Update any code that stores, parses, or compares IDs.
</Step>
<Step>
### Test, then remove the old calls
Verify the new flow against your account, then delete the deprecated calls.
</Step>
</Steps>
The main data differences are as follows:
- ID format changed from number to string (e.g. `42` to `envelope_abc123`)
- pageNumber becomes page
- pageX becomes positionX
- pageY becomes positionY
See the [Documents API](/docs/developers/api/documents) and [Templates API](/docs/developers/api/templates) for the full envelope reference.
### Deprecated V1 API Endpoints
Full reference in the [V1 OpenAPI reference](https://openapi-v1.documenso.com).
| Deprecated endpoint | Replacement |
| -------------------------------------------------------- | ----------------------------------------------------- |
| `GET /api/v1/documents` | `GET /api/v2/envelope` |
| `GET /api/v1/documents/{id}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v1/documents` | `POST /api/v2/envelope/create` |
| `POST /api/v1/documents/{id}/send` | `POST /api/v2/envelope/distribute` |
| `POST /api/v1/documents/{id}/resend` | `POST /api/v2/envelope/redistribute` |
| `DELETE /api/v1/documents/{id}` | `POST /api/v2/envelope/delete` |
| `GET /api/v1/documents/{id}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
| `POST /api/v1/documents/{id}/recipients` | `POST /api/v2/envelope/recipient/create-many` |
| `PATCH /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/update-many` |
| `DELETE /api/v1/documents/{id}/recipients/{recipientId}` | `POST /api/v2/envelope/recipient/delete` |
| `POST /api/v1/documents/{id}/fields` | `POST /api/v2/envelope/field/create-many` |
| `PATCH /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/update-many` |
| `DELETE /api/v1/documents/{id}/fields/{fieldId}` | `POST /api/v2/envelope/field/delete` |
| `GET /api/v1/templates` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
| `GET /api/v1/templates/{id}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v1/templates` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `DELETE /api/v1/templates/{id}` | `POST /api/v2/envelope/delete` |
| `POST /api/v1/templates/{templateId}/create-document` | `POST /api/v2/envelope/use` |
| `POST /api/v1/templates/{templateId}/generate-document` | `POST /api/v2/envelope/use` |
### Deprecated V2 API Endpoints
Full reference in the [V2 OpenAPI reference](https://openapi.documenso.com).
#### Documents
| Deprecated endpoint | Replacement |
| ------------------------------------------------- | ----------------------------------------------------- |
| `GET /api/v2/document` | `GET /api/v2/envelope` |
| `GET /api/v2/document/{documentId}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v2/document/get-many` | `POST /api/v2/envelope/get-many` |
| `POST /api/v2/document/create` | `POST /api/v2/envelope/create` |
| `POST /api/v2/document/create/beta` | `POST /api/v2/envelope/create` |
| `POST /api/v2/document/update` | `POST /api/v2/envelope/update` |
| `POST /api/v2/document/delete` | `POST /api/v2/envelope/delete` |
| `POST /api/v2/document/duplicate` | `POST /api/v2/envelope/duplicate` |
| `POST /api/v2/document/distribute` | `POST /api/v2/envelope/distribute` |
| `POST /api/v2/document/redistribute` | `POST /api/v2/envelope/redistribute` |
| `GET /api/v2/document/attachment` | `GET /api/v2/envelope/attachment` |
| `POST /api/v2/document/attachment/create` | `POST /api/v2/envelope/attachment/create` |
| `POST /api/v2/document/attachment/update` | `POST /api/v2/envelope/attachment/update` |
| `POST /api/v2/document/attachment/delete` | `POST /api/v2/envelope/attachment/delete` |
| `GET /api/v2/document/{documentId}/download` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
| `GET /api/v2/document/{documentId}/download-beta` | `GET /api/v2/envelope/item/{envelopeItemId}/download` |
#### Templates
| Deprecated endpoint | Replacement |
| ------------------------------------- | ------------------------------------------------ |
| `GET /api/v2/template` | `GET /api/v2/envelope` (with `type=TEMPLATE`) |
| `GET /api/v2/template/{templateId}` | `GET /api/v2/envelope/{envelopeId}` |
| `POST /api/v2/template/get-many` | `POST /api/v2/envelope/get-many` |
| `POST /api/v2/template/create` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `POST /api/v2/template/create/beta` | `POST /api/v2/envelope/create` (`type=TEMPLATE`) |
| `POST /api/v2/template/update` | `POST /api/v2/envelope/update` |
| `POST /api/v2/template/duplicate` | `POST /api/v2/envelope/duplicate` |
| `POST /api/v2/template/delete` | `POST /api/v2/envelope/delete` |
| `POST /api/v2/template/use` | `POST /api/v2/envelope/use` |
| `POST /api/v2/template/direct/create` | **Pending replacement** |
| `POST /api/v2/template/direct/delete` | **Pending replacement** |
| `POST /api/v2/template/direct/toggle` | **Pending replacement** |
#### Document fields
| Deprecated endpoint | Replacement |
| ----------------------------------------- | ----------------------------------------- |
| `GET /api/v2/document/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
| `POST /api/v2/document/field/create` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/document/field/create-many` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/document/field/update` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/document/field/update-many` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/document/field/delete` | `POST /api/v2/envelope/field/delete` |
#### Template fields
| Deprecated endpoint | Replacement |
| ----------------------------------------- | ----------------------------------------- |
| `GET /api/v2/template/field/{fieldId}` | `GET /api/v2/envelope/field/{fieldId}` |
| `POST /api/v2/template/field/create` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/template/field/create-many` | `POST /api/v2/envelope/field/create-many` |
| `POST /api/v2/template/field/update` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/template/field/update-many` | `POST /api/v2/envelope/field/update-many` |
| `POST /api/v2/template/field/delete` | `POST /api/v2/envelope/field/delete` |
#### Document recipients
| Deprecated endpoint | Replacement |
| ---------------------------------------------- | ---------------------------------------------- |
| `GET /api/v2/document/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
| `POST /api/v2/document/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/document/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/document/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/document/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/document/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
#### Template recipients
| Deprecated endpoint | Replacement |
| ---------------------------------------------- | ---------------------------------------------- |
| `GET /api/v2/template/recipient/{recipientId}` | `GET /api/v2/envelope/recipient/{recipientId}` |
| `POST /api/v2/template/recipient/create` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/template/recipient/create-many` | `POST /api/v2/envelope/recipient/create-many` |
| `POST /api/v2/template/recipient/update` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/template/recipient/update-many` | `POST /api/v2/envelope/recipient/update-many` |
| `POST /api/v2/template/recipient/delete` | `POST /api/v2/envelope/recipient/delete` |
### Embedding components
| Deprecated component | Replacement |
| ----------------------- | --------------------- |
| `EmbedCreateDocumentV1` | `EmbedCreateEnvelope` |
| `EmbedCreateTemplateV1` | `EmbedCreateEnvelope` |
| `EmbedUpdateDocumentV1` | `EmbedUpdateEnvelope` |
| `EmbedUpdateTemplateV1` | `EmbedUpdateEnvelope` |
See the [embedding guide](/docs/developers/embedding) for the envelope components.
## FAQ
<Accordions>
<Accordion title="What happens on 1 March 2027?">
The deprecated V1 API, the V2 endpoints listed above, and the V1 embedding components are removed.
Requests to them will fail, so migrate to the envelope API before that date.
</Accordion>
<Accordion title="Will my existing documents and templates keep working?">
Yes. Documents and templates you already created remain in your account and continue to work. They will automatically be converted to envelopes. Only
the deprecated endpoints you call are going away. Your data is not deleted.
</Accordion>
<Accordion title="Do I need a new API token?">
No. Authentication is unchanged. The same API token works for the envelope endpoints under
`https://app.documenso.com/api/v2`.
</Accordion>
<Accordion title="What is the difference between a document and a template now?">
Both are envelopes, distinguished by a `type` field of `DOCUMENT` or `TEMPLATE`. They share the same
endpoints, recipients, fields, and attachments.
</Accordion>
<Accordion title="I use an official SDK, what should I do?">
The function calls to the legacy endpoints will break on the 1st of March 2027. Update to the latest SDK version and switch to its envelope methods.
The deprecated document and template methods map to the envelope endpoints in the tables above.
</Accordion>
<Accordion title="I need more time or help migrating">
Reach out to [support@documenso.com](mailto:support@documenso.com) with your use case and we will
help you plan the migration.
</Accordion>
</Accordions>
## Getting help
- [V2 OpenAPI reference](https://openapi.documenso.com): the up-to-date envelope API.
- [V1 OpenAPI reference](https://openapi-v1.documenso.com): the deprecated V1 API.
- [support@documenso.com](mailto:support@documenso.com): migration questions and extensions.
## See also
- [Documents API](/docs/developers/api/documents): create and manage envelopes
- [Templates API](/docs/developers/api/templates): work with templates and direct links
- [Fields API](/docs/developers/api/fields) and [Recipients API](/docs/developers/api/recipients)
- [API Versioning](/docs/developers/api/versioning): how Documenso versions the public API
@@ -11,7 +11,7 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
## HTTP Rate Limits
**Limit:** 100 requests per minute per IP address
**Limit:** 100 requests per minute per IP address
**Response:** 429 Too Many Requests
### Rate Limit Response
@@ -6,6 +6,8 @@ description: Create documents from reusable templates via API.
import { Callout } from 'fumadocs-ui/components/callout';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
<Callout type="warn">
This guide may not reflect the latest endpoints or parameters. For an always up-to-date reference,
see the [OpenAPI Reference](https://openapi.documenso.com).
@@ -5,6 +5,8 @@ description: Versioning information for the Documenso public API.
import { Callout } from 'fumadocs-ui/components/callout';
<EnvelopeWarning />
## Overview
Documenso uses API versioning to manage changes to the public API. This allows us to introduce new features, fix bugs, and make other changes without breaking existing integrations.
@@ -19,7 +21,16 @@ Also, we may deprecate certain features or endpoints in the API. When we depreca
---
## Documents, Templates, and Envelopes
Documenso has unified documents and templates into a single resource called an **envelope**. New integrations should create documents and templates through the `/envelope/*` endpoints. The `POST /document/create` and `POST /template/create` endpoints (including their `/beta` variants) are deprecated in favor of `POST /envelope/create`.
See [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) for the rationale and step-by-step migration examples.
---
## See Also
- [Migrating to the Envelope API](/docs/developers/api/migrate-to-envelopes) - Move from the document and template create endpoints
- [Authentication](/docs/developers/getting-started/authentication) - API authentication guide
- [Rate Limits](/docs/developers/api/rate-limits) - API rate limit details
@@ -8,6 +8,8 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
## Workflow 1: Send a Document for Signature
The most common workflow: upload a PDF, add recipients with signature fields, and send for signing.
@@ -3,6 +3,8 @@ title: Examples
description: Common integration patterns and end-to-end workflows.
---
<EnvelopeWarning />
<Cards>
<Card
title="Common Workflows"
@@ -7,6 +7,8 @@ import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
<EnvelopeWarning />
## Prerequisites
- A Documenso account (cloud or self-hosted)
@@ -7,6 +7,8 @@ import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
<EnvelopeWarning />
## Prerequisites
Before starting, you need:
@@ -3,6 +3,8 @@ title: Getting Started
description: Get your API key and make your first API call.
---
<EnvelopeWarning />
<Cards>
<Card
title="Authentication"
@@ -3,6 +3,8 @@ title: Developer Guide
description: Integrate Documenso into your applications using the REST API, webhooks, and embedding options.
---
<EnvelopeWarning />
## Getting Started
<Cards>
@@ -15,17 +15,16 @@ Pick the one that fits your needs the best.
## Tech Stack
- [TypeScript](https://www.typescriptlang.org/) - Language
- [React Router v7](https://reactrouter.com/) - Framework
- [Hono](https://hono.dev/) - Server
- [Typescript](https://www.typescriptlang.org/) - Language
- [React Router](https://reactrouter.com/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [Tailwind CSS](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) + [Radix UI](https://www.radix-ui.com/) - Component Library
- [Tailwind](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
- [react-email](https://react.email/) - Email Templates
- [Lingui](https://lingui.dev/) - Internationalization
- [tRPC](https://trpc.io/) - API
- [@libpdf/core](https://www.npmjs.com/package/@libpdf/core) - PDF Signing and Manipulation
- [pdf.js](https://mozilla.github.io/pdf.js/) - Viewing PDFs
- [@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
- [Stripe](https://stripe.com/) - Payments
<div className="mt-16 flex items-center justify-center gap-4">
@@ -278,9 +278,7 @@ 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 [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:
For development or testing, use a local SMTP server like [Mailhog](https://github.com/mailhog/MailHog) or [Mailpit](https://github.com/axllent/mailpit):
```bash
# Using Docker
@@ -86,21 +86,6 @@ 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
@@ -272,12 +257,6 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft. Existing linked users can still sign in | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC, including the organisation portal | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | |
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch. Disable all signin methods application-wide | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin. Also closes `/forgot-password` and `/reset-password` | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable the automatic `/signin` redirect when OIDC is the only enabled transport | `false` |
| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | |
| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` |
@@ -309,44 +288,6 @@ NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
NEXT_PUBLIC_DISABLE_SIGNUP="true"
```
### Sign-in Restrictions
You can control which methods are available for users to sign in with the following environment variables:
- **`NEXT_PUBLIC_DISABLE_SIGNIN`** (master switch): Set to `true` to block all signin methods (email/password, Google, Microsoft, OIDC). Hides every signin entry point on `/signin` and rejects email/password signin server-side with a `SIGNIN_DISABLED` error.
- **`NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN`**: Set to `true` to disable email/password signin only. The email/password form is hidden, the `/forgot-password` and `/reset-password` pages redirect to `/signin`, and the corresponding server endpoints reject requests. SSO signin is unaffected.
- **`NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`**, **`NEXT_PUBLIC_DISABLE_OIDC_SIGNIN`**: Set to `true` to hide the matching SSO button on the signin page. Useful when an SSO provider is kept configured for account linking but not advertised as a signin entry point.
These flags are opt-in: when none are set, signin behaviour is unchanged from a stock Documenso instance.
```bash
# Allow only OIDC signin (e.g. enterprise SSO-only)
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
# Or disable signin entirely
NEXT_PUBLIC_DISABLE_SIGNIN="true"
```
### OIDC Auto-redirect
When OIDC is the only enabled signin transport on your instance, `/signin` automatically redirects users straight to the OIDC provider instead of showing the signin form. The page renders a spinner while the redirect happens. No extra configuration is required — disabling every other signin method is enough to trigger it.
- **`NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT`**: Set to `true` to opt out of the automatic redirect and keep rendering the signin page even when OIDC is the only enabled transport.
The redirect only triggers when OIDC is configured and email/password, Google, and Microsoft signin are all disabled. If any other transport remains enabled, the signin form is shown as normal.
```bash
# OIDC-only signin: disabling all other methods auto-redirects to the provider
NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
# Opt out of the auto-redirect while still OIDC-only
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
```
---
## AI Features
@@ -490,16 +431,6 @@ NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password"
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP="true"
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP="true"
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org"
# Sign-in restrictions (optional)
# NEXT_PUBLIC_DISABLE_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN="true"
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN="true"
# Opt out of the automatic OIDC redirect when OIDC is the only enabled transport (optional)
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT="true"
```
---
@@ -163,19 +163,6 @@ NEXT_PUBLIC_DISABLE_SIGNUP=false
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=true
# NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=true
# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org
# Signin restrictions (optional)
# Master switch — disables every signin method
# NEXT_PUBLIC_DISABLE_SIGNIN=true
# Per-method switches (optional). Each disables that signin path.
# NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=true
# NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=true
# NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=true
# NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=true
# When OIDC is the only enabled transport, /signin auto-redirects to the provider.
# Set this to opt out and keep showing the signin page (optional).
# NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=true
```
<Callout type="info">Generate secure secrets using: `openssl rand -base64 32`</Callout>
@@ -112,12 +112,6 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP` | Block new accounts via Microsoft OAuth | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal) | `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN` | Hide the Microsoft signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment).
@@ -235,7 +235,7 @@ spec:
```
<Callout type="info">
Pin to a specific image tag (e.g., `documenso/documenso:<version>`) in production instead of `latest`
Pin to a specific image tag (e.g., `documenso/documenso:1.5.0`) 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 22 or later
- npm 11 or later
- Node.js 20 or later
- npm
- PostgreSQL 14 or later
- A Linux server (for systemd service setup)
@@ -159,12 +159,6 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP`| Block new accounts via Microsoft OAuth | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNUP` | Block new accounts via OIDC (incl. organisation portal)| `false` |
| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | |
| `NEXT_PUBLIC_DISABLE_SIGNIN` | Master switch — disable all signin methods | `false` |
| `NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN` | Disable email/password signin only | `false` |
| `NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN` | Hide the Google signin button | `false` |
| `NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN`| Hide the Microsoft signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_SIGNIN` | Hide the OIDC signin button | `false` |
| `NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT` | Disable auto-redirect to OIDC when it is the only transport | `false` |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - |
| `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` |
@@ -124,16 +124,12 @@ 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 |
| `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).
| Container | Purpose | Port |
| ----------- | -------------------------------- | ----- |
| `documenso` | Main application | 3000 |
| `database` | PostgreSQL database | 54320 |
| `maildev` | Local email testing server | 2500 |
| `minio` | S3-compatible storage (optional) | 9000 |
## Useful Commands
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
| Requirement | Version |
| ----------- | ------- |
| Node.js | 22+ |
| npm | 11+ |
| Node.js | 18+ |
| npm | 8+ |
---
@@ -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 < 22 | Modern JavaScript features required |
| Node.js < 18 | Modern JavaScript features required |
---
@@ -92,7 +92,7 @@ Use a specific version tag in production:
```bash
# Good — predictable, reproducible
docker pull documenso/documenso:<version>
docker pull documenso/documenso:1.8.0
# Risky — may pull breaking changes
docker pull documenso/documenso:latest
@@ -27,14 +27,6 @@ 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:<version>
docker pull documenso/documenso:1.6.0
```
Replace `<version>` with your target version.
Replace `1.6.0` 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:<version>
documenso/documenso:1.6.0
```
</Step>
@@ -223,14 +223,14 @@ Edit `compose.yml` or your `.env` file to specify the new version:
```yaml
services:
documenso:
image: documenso/documenso:<version>
image: documenso/documenso:1.6.0
```
Or if using environment variable substitution:
```bash
# In .env
DOCUMENSO_VERSION=<version>
DOCUMENSO_VERSION=1.6.0
```
```yaml
@@ -283,7 +283,7 @@ Edit the deployment directly:
```bash
kubectl set image deployment/documenso \
documenso=documenso/documenso:<version> \
documenso=documenso/documenso:1.6.0 \
-n documenso
```
@@ -295,7 +295,7 @@ spec:
spec:
containers:
- name: documenso
image: documenso/documenso:<version>
image: documenso/documenso:1.6.0
```
Then apply:
@@ -421,12 +421,12 @@ To run migrations manually before upgrading:
```bash
# Pull the new image
docker pull documenso/documenso:<version>
docker pull documenso/documenso:1.6.0
# Run migrations only
docker run --rm \
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@host:5432/documenso" \
documenso/documenso:<version> \
documenso/documenso:1.6.0 \
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:<previous-version>
documenso/documenso:1.5.0
```
</Tab>
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"dev": "next dev --port 3004",
"start": "next start",
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
"postinstall": "fumadocs-mdx"
@@ -0,0 +1,19 @@
import { Callout } from 'fumadocs-ui/components/callout';
const MIGRATION_GUIDE_HREF = '/docs/developers/api/migrate-to-envelopes';
/**
* Deprecation banner steering API consumers away from the legacy document and
* template create endpoints and towards the unified Envelope API.
*
* Registered globally in `mdx-components.tsx`, so it can be used in any MDX page
* as `<EnvelopeWarning />` without an explicit import.
*/
export function EnvelopeWarning() {
return (
<Callout type="error">
<strong>Documents and templates are being deprecated and replaced by envelopes.</strong>{' '}
<a href={MIGRATION_GUIDE_HREF}>Read the migration guide here.</a>
</Callout>
);
}
+2
View File
@@ -1,6 +1,7 @@
import * as TabsComponents from 'fumadocs-ui/components/tabs';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
import { EnvelopeWarning } from '@/components/mdx/envelope-warning';
import { Mermaid } from '@/components/mdx/mermaid';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -9,6 +10,7 @@ export function getMDXComponents(components?: MDXComponents): any {
...defaultMdxComponents,
...TabsComponents,
Mermaid,
EnvelopeWarning,
...components,
};
}
+22
View File
@@ -0,0 +1,22 @@
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"]
+93 -7
View File
@@ -1,14 +1,100 @@
# @documenso/remix
# Welcome to React Router!
The main Documenso web application. Built with [React Router v7](https://reactrouter.com/) and served by a [Hono](https://hono.dev/) server.
A modern, production-ready template for building full-stack React applications using React Router.
This package is part of the Documenso monorepo and is not meant to be run standalone. Use the root scripts instead.
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
- 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).
## 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:
```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.
@@ -21,8 +21,6 @@ import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { FormStickySaveBar } from './form-sticky-save-bar';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
@@ -73,82 +71,38 @@ export function BrandingPreferencesForm({
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
const initialColors = parsedColors.success ? parsedColors.data : {};
// The saved state the form maps to. Used both as the reactive `values` source and as
// the explicit target for a Reset (see handleReset).
const savedValues: TBrandingPreferencesFormSchema = {
brandingEnabled: settings.brandingEnabled ?? null,
brandingUrl: settings.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
brandingColors: initialColors,
brandingCss: settings.brandingCss ?? '',
};
const form = useForm<TBrandingPreferencesFormSchema>({
values: savedValues,
values: {
brandingEnabled: settings.brandingEnabled ?? null,
brandingUrl: settings.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
brandingColors: initialColors,
brandingCss: settings.brandingCss ?? '',
},
resolver: zodResolver(ZBrandingPreferencesFormSchema),
});
const isBrandingEnabled = form.watch('brandingEnabled');
const getSavedLogoPreviewUrl = () => {
if (!settings.brandingLogo) {
return '';
}
const file = JSON.parse(settings.brandingLogo);
if (!('type' in file) || !('data' in file)) {
return '';
}
const logoUrl =
context === 'Team'
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
return `${logoUrl}?v=${Date.now()}`;
};
useEffect(() => {
const savedLogoPreviewUrl = getSavedLogoPreviewUrl();
if (settings.brandingLogo) {
const file = JSON.parse(settings.brandingLogo);
if (savedLogoPreviewUrl) {
setPreviewUrl(savedLogoPreviewUrl);
if ('type' in file && 'data' in file) {
const logoUrl =
context === 'Team'
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
setPreviewUrl(logoUrl + '?v=' + Date.now());
setHasLoadedPreview(true);
}
}
setHasLoadedPreview(true);
}, [settings.brandingLogo]);
// Reset the form to the saved values. The form is driven by the `values` prop (no
// `defaultValues`), so `reset()` with no argument doesn't re-baseline the dirty check;
// passing the saved values clears the per-field dirty tracking (dirtyFields).
const handleReset = () => {
setPreviewUrl(getSavedLogoPreviewUrl());
form.reset(savedValues);
};
// `formState.isDirty` is unreliable for a `values`-driven form: after a reset (or a
// save + refetch) it can stay true even though every field already matches its saved
// value and `dirtyFields` is empty. Derive the flag from `dirtyFields` instead so the
// sticky save bar reliably disappears.
const hasUnsavedChanges = Object.keys(form.formState.dirtyFields).length > 0;
// Re-baseline the form to the just-saved state after a successful submit. The `values`
// prop re-syncs most fields once the route refetches, but write-only fields (the logo
// is a File that isn't reflected back into `values`) would otherwise stay dirty and
// keep the save bar visible. Relies on the page handler rethrowing on error so we only
// re-baseline on success.
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
} catch {
return;
}
form.reset(form.getValues());
});
// Cleanup ObjectURL on unmount or when previewUrl changes
useEffect(() => {
return () => {
@@ -160,7 +114,7 @@ export function BrandingPreferencesForm({
return (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
@@ -213,7 +167,7 @@ export function BrandingPreferencesForm({
/>
<div className="relative flex w-full flex-col gap-y-4">
{!isBrandingEnabled && <div className="absolute inset-0 z-30 bg-background/60" />}
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
<FormField
control={form.control}
@@ -367,7 +321,7 @@ export function BrandingPreferencesForm({
{hasAdvancedBranding && (
<div className="relative flex w-full flex-col gap-y-6">
{!isBrandingEnabled && <div className="absolute inset-0 z-30 bg-background/60" />}
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
<div>
<FormLabel>
@@ -584,11 +538,11 @@ export function BrandingPreferencesForm({
</div>
)}
<FormStickySaveBar
isDirty={hasUnsavedChanges}
isSubmitting={form.formState.isSubmitting}
onReset={handleReset}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
@@ -21,6 +21,7 @@ import { ReminderSettingsPicker } from '@documenso/ui/components/document/remind
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Form,
@@ -45,7 +46,6 @@ import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
import { FormStickySaveBar } from './form-sticky-save-bar';
/**
* Can't infer this from the schema since we need to keep the schema inside the component to allow
@@ -147,21 +147,9 @@ export const DocumentPreferencesForm = ({
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
} catch {
// The page handler surfaces its own error toast. Keep the form dirty so
// the save bar stays visible and the user can retry.
return;
}
form.reset(data);
});
return (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{!isPersonalLayoutMode && (
<FormField
@@ -768,11 +756,11 @@ export const DocumentPreferencesForm = ({
/>
)}
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
@@ -4,6 +4,7 @@ import { DEFAULT_DOCUMENT_EMAIL_SETTINGS, ZDocumentEmailSettingsSchema } from '@
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
@@ -21,8 +22,6 @@ import type { TeamGlobalSettings } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
const ZEmailPreferencesFormSchema = z.object({
emailId: z.string().nullable(),
emailReplyTo: zEmail().nullable(),
@@ -60,21 +59,9 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
const emails = emailData?.data || [];
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
} catch {
// The page handler surfaces its own error toast. Keep the form dirty so
// the save bar stays visible and the user can retry.
return;
}
form.reset(data);
});
return (
<Form {...form}>
<form onSubmit={handleFormSubmit}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex h-full max-w-2xl flex-col gap-y-6" disabled={form.formState.isSubmitting}>
{organisation.organisationClaim.flags.emailDomains && (
<FormField
@@ -216,11 +203,11 @@ export const EmailPreferencesForm = ({ settings, onFormSubmit, canInherit }: Ema
)}
/>
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
@@ -1,119 +0,0 @@
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
export type FormStickySaveBarProps = {
isDirty: boolean;
isSubmitting: boolean;
onReset: () => void;
};
/**
* A single `position: sticky` bar rendered at the bottom of the form.
*
* - When the form's end is on screen it settles into place as a plain footer (just the
* Reset / Save buttons).
* - When the form's end is scrolled off, it sticks to the bottom of the viewport and
* shows the "unsaved changes" pill chrome.
*
* Because it's the same element in the form's flow, it auto-aligns to the form and the
* float <-> dock hand-off is a native, scroll-linked transition (no measurement, no
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
* the pill chrome.
*/
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
const { t } = useLingui();
const sentinelRef = useRef<HTMLDivElement>(null);
const [isStuck, setIsStuck] = useState(false);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) {
return;
}
// The sentinel sits at the bar's resting position (the end of the form). While the
// bar is stuck to the bottom of the viewport the sentinel is scrolled past (out of
// view); once you reach the form's end it comes into view and the bar settles.
const observer = new IntersectionObserver(
([entry]) => {
setIsStuck(!entry.isIntersecting);
},
{
root: null,
rootMargin: '0px 0px -24px 0px',
threshold: 0,
},
);
observer.observe(sentinel);
return () => {
observer.disconnect();
};
}, []);
// Show the floating pill chrome only when there are unsaved changes AND the form's
// end is off screen.
const isFloating = isDirty && isStuck;
return (
<>
<div
data-testid="form-sticky-save-bar"
className={cn(
'z-40 flex min-h-9 min-w-0 items-center gap-x-2 rounded-lg py-4 transition-[margin,padding,background-color,border-color,box-shadow] duration-200 md:gap-x-4',
isDirty ? 'sticky bottom-6' : '',
// On mobile the docked and floating states are geometrically identical (only
// paint changes): a horizontal bleed there overflows the narrow viewport and
// fights the IntersectionObserver (oscillation + partial hiding). From `sm` up
// there's room, so we restore the original chrome — the island bleeds 8px past
// the form when floating, and the buttons sit flush with the fields when docked.
isFloating
? 'border border-border bg-background px-4 shadow-2xl sm:-mx-2'
: 'border border-transparent bg-transparent px-4 shadow-none sm:px-0',
)}
>
<AnimatePresence initial={false}>
{isFloating && (
<motion.div
key="notice"
role="region"
aria-label={t`Unsaved changes`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="flex min-h-9 min-w-0 items-center gap-x-2 text-sm"
>
<AlertTriangleIcon className="h-5 w-5 flex-shrink-0 text-destructive" />
<span className="font-medium text-xs md:text-sm">
<Trans>You have unsaved changes</Trans>
</span>
</motion.div>
)}
</AnimatePresence>
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
{isDirty && (
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
<Trans>Undo</Trans>
</Button>
)}
<Button type="submit" className="shrink-0" size="sm" loading={isSubmitting} disabled={!isDirty}>
<Trans>Save changes</Trans>
</Button>
</div>
</div>
{/* Sentinel: detects when the sticky bar is floating (stuck) vs settled (docked). */}
<div ref={sentinelRef} aria-hidden className="pointer-events-none h-px w-full" />
</>
);
};
@@ -4,6 +4,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -11,12 +12,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
name: true,
url: true,
@@ -137,11 +137,36 @@ export const OrganisationUpdateForm = () => {
)}
/>
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
<div className="flex flex-row justify-end space-x-4">
<AnimatePresence>
{form.formState.isDirty && (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<Button type="button" variant="secondary" onClick={() => form.reset()}>
<Trans>Reset</Trans>
</Button>
</motion.div>
)}
</AnimatePresence>
<Button
type="submit"
className="transition-opacity"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
<Trans>Update organisation</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
+49 -63
View File
@@ -58,7 +58,6 @@ export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
export type SignInFormProps = {
className?: string;
initialEmail?: string;
isEmailPasswordSigninEnabled?: boolean;
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
@@ -69,7 +68,6 @@ export type SignInFormProps = {
export const SignInForm = ({
className,
initialEmail,
isEmailPasswordSigninEnabled = true,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
@@ -326,78 +324,66 @@ export const SignInForm = ({
<Form {...form}>
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting || isPasskeyLoading}>
{isEmailPasswordSigninEnabled && (
<>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Password</Trans>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
<FormMessage />
<p className="mt-2 text-right">
<Link
to="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
>
<Trans>Forgot your password?</Trans>
</Link>
</p>
</FormItem>
)}
/>
<p className="mt-2 text-right">
<Link to="/forgot-password" className="text-muted-foreground text-sm duration-200 hover:opacity-70">
<Trans>Forgot your password?</Trans>
</Link>
</p>
</FormItem>
)}
/>
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{
size: 'flexible',
appearance: 'always',
}}
/>
)}
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</>
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{
size: 'flexible',
appearance: 'always',
}}
/>
)}
<Button type="submit" size="lg" loading={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
{!isEmbeddedRedirect && (
<>
{isEmailPasswordSigninEnabled && hasSocialAuthEnabled && (
{hasSocialAuthEnabled && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="h-px flex-1 bg-border" />
<span className="bg-transparent text-muted-foreground">
@@ -2,6 +2,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -9,12 +10,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { FormStickySaveBar } from './form-sticky-save-bar';
export type UpdateTeamDialogProps = {
teamId: number;
teamName: string;
@@ -135,11 +135,36 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
)}
/>
<FormStickySaveBar
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
/>
<div className="flex flex-row justify-end space-x-4">
<AnimatePresence>
{form.formState.isDirty && (
<motion.div
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<Button type="button" variant="secondary" onClick={() => form.reset()}>
<Trans>Reset</Trans>
</Button>
</motion.div>
)}
</AnimatePresence>
<Button
type="submit"
className="transition-opacity"
disabled={!form.formState.isDirty}
loading={form.formState.isSubmitting}
>
<Trans>Update team</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
@@ -1,4 +1,3 @@
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';
@@ -54,7 +53,7 @@ export const DocumentSigningAttachmentsPopover = ({
{attachments?.data.map((attachment) => (
<a
key={attachment.id}
href={toSafeHref(attachment.data)}
href={attachment.data}
title={attachment.data}
target="_blank"
rel="noopener noreferrer"
@@ -1,6 +1,5 @@
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';
@@ -25,7 +24,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').refine(isHttpUrl, 'URL must use the http or https protocol'),
url: z.string().url('Must be a valid URL'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
@@ -157,7 +156,7 @@ export const DocumentAttachmentsPopover = ({
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={toSafeHref(attachment.data)}
href={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;
envelopeId: string;
documentId: number;
};
export const DocumentAuditLogDownloadButton = ({ className, envelopeId }: DocumentAuditLogDownloadButtonProps) => {
export const DocumentAuditLogDownloadButton = ({ className, documentId }: DocumentAuditLogDownloadButtonProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -22,7 +22,7 @@ export const DocumentAuditLogDownloadButton = ({ className, envelopeId }: Docume
const onDownloadAuditLogsClick = async () => {
try {
const { data, envelopeTitle } = await downloadAuditLogs({ envelopeId });
const { data, envelopeTitle } = await downloadAuditLogs({ documentId });
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;
envelopeId: string;
documentId: number;
documentStatus: DocumentStatus;
};
export const DocumentCertificateDownloadButton = ({
className,
envelopeId,
documentId,
documentStatus,
}: DocumentCertificateDownloadButtonProps) => {
const { toast } = useToast();
@@ -29,7 +29,7 @@ export const DocumentCertificateDownloadButton = ({
const onDownloadCertificatesClick = async () => {
try {
const { data, envelopeTitle } = await downloadCertificate({ envelopeId });
const { data, envelopeTitle } = await downloadCertificate({ documentId });
const buffer = new Uint8Array(base64.decode(data));
const blob = new Blob([buffer], { type: 'application/pdf' });
@@ -1,6 +1,5 @@
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';
@@ -23,7 +22,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').refine(isHttpUrl, 'URL must use the http or https protocol'),
url: z.string().url('Must be a valid URL'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
@@ -118,7 +117,7 @@ export const EmbeddedEditorAttachmentPopover = ({
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{attachment.label}</p>
<a
href={toSafeHref(attachment.data)}
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="truncate text-muted-foreground text-xs underline hover:text-foreground"
@@ -49,13 +49,6 @@ 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,
@@ -244,26 +237,10 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
fieldGroup.off('transformend');
fieldGroup.off('dragend');
// 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) => {
// Set up field selection.
fieldGroup.on('click', () => {
removePendingField();
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]);
}
setSelectedFields([fieldGroup]);
pageLayer.current?.batchDraw();
});
@@ -468,18 +445,43 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
}
});
// 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`.
// Clicks should select/deselect shapes
currentStage.on('click tap', (e) => {
// If we are selecting with the marquee rectangle, do nothing.
// if we are selecting with rect, 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);
}
});
@@ -519,48 +521,13 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
setSelectedFields(liveSelectedFieldGroups);
}
// Mirror the editor's single selected field onto the canvas (Konva) selection.
//
// `addField` already marks a newly created field as the selected field, so this
// makes a field placed via the palette (drag-drop) or marquee creation show its
// resize handles immediately -- no second click needed. It also clears the canvas
// selection when the selected field is cleared (e.g. when the author starts
// placing another field), so the floating action toolbar can't intercept the next
// placement click. Runs after the render loop above so the field's group exists.
const selectedFormId = editorFields.selectedField?.formId ?? null;
const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1;
if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) {
const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId;
if (!isAlreadySelected) {
const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`);
if (fieldGroupToSelect instanceof Konva.Group) {
setSelectedFields([fieldGroupToSelect], { isAutoSelect: true });
}
}
} else if (selectedFormId === null && isSingleCanvasSelection) {
setSelectedFields([]);
}
// Rerender the transformer
interactiveTransformer.current?.forceUpdate();
pageLayer.current.batchDraw();
}, [
localPageFields,
selectedKonvaFieldGroups,
overlappingFieldFormIds,
isFieldChanging,
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));
}, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]);
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()),
@@ -696,30 +663,25 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
return (
<>
{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,
}}
/>
)}
{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,
}}
/>
)}
{pendingFieldCreation && (
<div
@@ -42,7 +42,6 @@ import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { CardDescription, CardHeader, CardTitle } from '@documenso/ui/primitives/card';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Dialog,
@@ -52,15 +51,7 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
@@ -103,7 +94,6 @@ export const ZAddSettingsFormSchema = z.object({
timezone: ZDocumentMetaTimezoneSchema.default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: ZDocumentMetaDateFormatSchema.default(DEFAULT_DOCUMENT_DATE_FORMAT),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional().default(DocumentDistributionMethod.EMAIL),
includeAuditLog: z.boolean().default(false),
redirectUrl: z
.string()
.optional()
@@ -204,7 +194,6 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
dateFormat: (envelope.documentMeta.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT) as TDocumentMetaDateFormat,
distributionMethod: envelope.documentMeta.distributionMethod || DocumentDistributionMethod.EMAIL,
includeAuditLog: envelope.documentMeta.includeAuditLog,
redirectUrl: envelope.documentMeta.redirectUrl ?? '',
language: envelope.documentMeta.language ?? 'en',
emailId: envelope.documentMeta.emailId ?? null,
@@ -262,7 +251,6 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
emailReplyTo,
envelopeExpirationPeriod,
reminderSettings,
includeAuditLog,
} = data.meta;
const parsedGlobalAccessAuth = z.array(ZDocumentAccessAuthTypesSchema).safeParse(data.globalAccessAuth);
@@ -292,7 +280,6 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
envelopeExpirationPeriod,
reminderSettings,
includeAuditLog,
},
});
@@ -937,33 +924,6 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
)}
/>
<FormField
control={form.control}
name="meta.includeAuditLog"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox
id="include-audit-log"
checked={field.value}
disabled={field.disabled}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
</FormControl>
<FormLabel className="ml-2 text-muted-foreground text-sm" htmlFor="include-audit-log">
<Trans>Include audit logs in signed PDF</Trans>
</FormLabel>
</div>
<FormDescription>
<Trans>Audit logs can still be downloaded separately from the document logs page.</Trans>
</FormDescription>
</FormItem>
)}
/>
{!isEmbedded && (
<FormField
control={form.control}
@@ -104,9 +104,6 @@ export default function OrganisationSettingsBrandingPage() {
description: t`We were unable to update your branding preferences at this time, please try again later`,
variant: 'destructive',
});
// Rethrow so the form knows the save failed and keeps the unsaved changes.
throw err;
}
};
@@ -105,8 +105,6 @@ export default function OrganisationSettingsDocumentPage() {
description: t`We were unable to update your document preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
@@ -49,8 +49,6 @@ export default function OrganisationSettingsGeneral() {
description: t`We were unable to update your email preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
@@ -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"
envelopeId={document.envelopeId}
documentId={document.id}
documentStatus={document.status}
/>
<DocumentAuditLogDownloadButton envelopeId={document.envelopeId} />
<DocumentAuditLogDownloadButton documentId={document.id} />
</div>
</div>
</div>
@@ -1,17 +1,14 @@
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,
@@ -39,8 +36,6 @@ export default function TeamsSettingsPage() {
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
const canConfigureBranding = organisation.organisationClaim.flags.allowCustomBranding || !IS_BILLING_ENABLED();
const canCustomBranding =
organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED();
@@ -99,9 +94,6 @@ export default function TeamsSettingsPage() {
description: t`We were unable to update your branding preferences at this time, please try again later`,
variant: 'destructive',
});
// Rethrow so the form knows the save failed and keeps the unsaved changes.
throw err;
}
};
@@ -120,61 +112,39 @@ export default function TeamsSettingsPage() {
subtitle={t`Here you can set preferences and defaults for branding.`}
/>
{canConfigureBranding ? (
<section>
<BrandingPreferencesForm
canInherit={true}
hasAdvancedBranding={canCustomBranding}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
<section>
<BrandingPreferencesForm
canInherit={true}
hasAdvancedBranding={canCustomBranding}
context="Team"
settings={teamWithSettings.teamSettings}
onFormSubmit={onBrandingPreferencesFormSubmit}
/>
{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">
{cssWarnings.length > 0 && (
<Alert variant="warning" className="mt-6">
<AlertTitle>
<Trans>Branding Preferences</Trans>
<Trans>CSS rules were dropped during sanitisation</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>Currently branding can only be configured for Teams and above plans.</Trans>
<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>
</div>
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<Button asChild variant="outline">
<Link to={`/o/${organisation.url}/settings/billing`}>
<Trans>Update Billing</Trans>
</Link>
</Button>
)}
</Alert>
)}
</Alert>
)}
</section>
</div>
);
}
@@ -96,8 +96,6 @@ export default function TeamsSettingsPage() {
description: t`We were unable to update your document preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
@@ -49,8 +49,6 @@ export default function TeamEmailSettingsGeneral() {
description: t`We were unable to update your email preferences at this time, please try again later`,
variant: 'destructive',
});
throw err;
}
};
@@ -1,7 +1,6 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
import { Link } from 'react-router';
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
import { appMetaTags } from '~/utils/meta';
@@ -10,14 +9,6 @@ export function meta() {
return appMetaTags(msg`Forgot Password`);
}
export async function loader() {
if (!isSigninEnabledForProvider('email')) {
throw redirect('/signin');
}
return null;
}
export default function ForgotPasswordPage() {
return (
<div className="w-screen max-w-lg px-4">
@@ -1,4 +1,3 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
@@ -14,10 +13,6 @@ export function meta() {
}
export async function loader({ params }: Route.LoaderArgs) {
if (!isSigninEnabledForProvider('email')) {
throw redirect('/signin');
}
const { token } = params;
const isValid = await getResetTokenValidity({ token });
@@ -1,8 +1,7 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
import { Link } from 'react-router';
import { appMetaTags } from '~/utils/meta';
@@ -10,14 +9,6 @@ export function meta() {
return appMetaTags(msg`Reset Password`);
}
export async function loader() {
if (!isSigninEnabledForProvider('email')) {
throw redirect('/signin');
}
return null;
}
export default function ResetPasswordPage() {
return (
<div className="w-screen max-w-lg px-4">
@@ -1,11 +1,8 @@
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_AUTO_REDIRECT_DISABLED,
IS_OIDC_SSO_ENABLED,
isSigninEnabledForProvider,
isSignupEnabledForProvider,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
@@ -14,7 +11,6 @@ import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, redirect, useSearchParams } from 'react-router';
@@ -32,20 +28,10 @@ export async function loader({ request }: Route.LoaderArgs) {
const { isAuthenticated } = await getOptionalSession(request);
// SSR env variables.
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED && isSigninEnabledForProvider('google');
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED && isSigninEnabledForProvider('microsoft');
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED && isSigninEnabledForProvider('oidc');
// Automatically redirect to OIDC when it is the only enabled signin transport,
// unless the redirect has been explicitly disabled via env.
const isOIDCOnlyTransport =
isOIDCSSOEnabled && !isEmailPasswordSigninEnabled && !isGoogleSSOEnabled && !isMicrosoftSSOEnabled;
const shouldAutoRedirectToOIDC = isOIDCOnlyTransport && !IS_OIDC_AUTO_REDIRECT_DISABLED;
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
const isSignupEnabled =
isSignupEnabledForProvider('email') ||
(IS_GOOGLE_SSO_ENABLED && isSignupEnabledForProvider('google')) ||
@@ -61,28 +47,18 @@ export async function loader({ request }: Route.LoaderArgs) {
}
return {
isEmailPasswordSigninEnabled,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
isSignupEnabled,
oidcProviderLabel,
returnTo,
shouldAutoRedirectToOIDC,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
const {
isEmailPasswordSigninEnabled,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
isSignupEnabled,
oidcProviderLabel,
returnTo,
shouldAutoRedirectToOIDC,
} = loaderData;
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, isSignupEnabled, oidcProviderLabel, returnTo } =
loaderData;
const { _ } = useLingui();
@@ -100,27 +76,6 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
setIsEmbeddedRedirect(params.get('embedded') === 'true');
}, []);
useEffect(() => {
if (!shouldAutoRedirectToOIDC) {
return;
}
void authClient.oidc.signIn({ redirectPath: returnTo ?? '/' });
}, [shouldAutoRedirectToOIDC, returnTo]);
if (shouldAutoRedirectToOIDC) {
return (
<div className="w-screen max-w-lg px-4">
<div className="flex flex-col items-center justify-center gap-y-4 py-12">
<Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground text-sm">
<Trans>Redirecting to {oidcProviderLabel || 'OIDC'}...</Trans>
</p>
</div>
</div>
);
}
return (
<div className="w-screen max-w-lg px-4">
<div className="z-10 rounded-xl border border-border bg-neutral-100 p-6 dark:bg-background">
@@ -140,7 +95,6 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
<hr className="-mx-6 my-4" />
<SignInForm
isEmailPasswordSigninEnabled={isEmailPasswordSigninEnabled}
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.14.0"
"version": "2.13.0"
}
+4 -11
View File
@@ -1,14 +1,7 @@
# 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
# General 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
+40 -212
View File
@@ -1,52 +1,20 @@
import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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 { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
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 { DocumentStatus, EnvelopeType } from '@prisma/client';
import contentDisposition from 'content-disposition';
import { EnvelopeType } from '@prisma/client';
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.
@@ -62,8 +30,24 @@ 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');
const apiToken = await resolveApiToken(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',
});
}
logger.info({
auth: 'api',
@@ -141,180 +125,6 @@ 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.
@@ -324,8 +134,24 @@ export const downloadRoute = new Hono<HonoEnv>()
try {
const { documentId, version } = c.req.valid('param');
const authorizationHeader = c.req.header('authorization');
const apiToken = await resolveApiToken(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',
});
}
logger.info({
auth: 'api',
@@ -374,9 +200,11 @@ export const downloadRoute = new Hono<HonoEnv>()
logger.error(error);
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
if (error.code === AppErrorCode.UNAUTHORIZED) {
return c.json({ error: error.message }, 401);
}
return c.json({ error: body.message, code: error.code }, status);
return c.json({ error: error.message }, 400);
}
return c.json({ error: 'Internal server error' }, 500);
@@ -30,17 +30,3 @@ 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
>;
-6
View File
@@ -64,12 +64,6 @@ services:
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNUP}
- NEXT_PUBLIC_DISABLE_OIDC_SIGNUP=${NEXT_PUBLIC_DISABLE_OIDC_SIGNUP}
- NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=${NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS}
- NEXT_PUBLIC_DISABLE_SIGNIN=${NEXT_PUBLIC_DISABLE_SIGNIN}
- NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN=${NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD_SIGNIN}
- NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN=${NEXT_PUBLIC_DISABLE_GOOGLE_SIGNIN}
- NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN=${NEXT_PUBLIC_DISABLE_MICROSOFT_SIGNIN}
- NEXT_PUBLIC_DISABLE_OIDC_SIGNIN=${NEXT_PUBLIC_DISABLE_OIDC_SIGNIN}
- NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT=${NEXT_PUBLIC_DISABLE_OIDC_AUTO_REDIRECT}
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
- NEXT_PRIVATE_SIGNING_PASSPHRASE=${NEXT_PRIVATE_SIGNING_PASSPHRASE}
- NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=${NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS}
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.14.0",
"version": "2.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.14.0",
"version": "2.13.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.1",
"@libpdf/core": "^0.4.0",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@marsidev/react-turnstile": "^1.5.0",
@@ -406,7 +406,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.14.0",
"version": "2.13.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
@@ -4661,9 +4661,9 @@
"license": "MIT"
},
"node_modules/@libpdf/core": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.1.tgz",
"integrity": "sha512-DWGxWw1na8oFPixz+b6kOgu4tMD8xJLDgyPGVUNqIswO5POHAxsqmTvUvDW0IrwHP7kFRHBV3I3T/KhTABf9rA==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.4.0.tgz",
"integrity": "sha512-G9nZRjf9DGDJaS/C23YWogk8akPM7O/6HfMslxVsKTKRbbbb+0szpQIetcGGUGRu7KtmBDmGDWCgz//DXSmq8A==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^2.2.0",
+3 -2
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.14.0",
"version": "2.13.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -35,6 +35,7 @@
"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",
@@ -87,7 +88,7 @@
"dependencies": {
"@ai-sdk/google-vertex": "3.0.81",
"@documenso/prisma": "*",
"@libpdf/core": "^0.4.1",
"@libpdf/core": "^0.4.0",
"@lingui/conf": "^5.6.0",
"@lingui/core": "^5.6.0",
"@prisma/extension-read-replicas": "^0.4.1",
+1 -1
View File
@@ -11,7 +11,7 @@ export const OpenAPIV1 = Object.assign(
title: 'Documenso API',
version: '1.0.0',
description:
'API V1 is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
'API V1 has been deprecated. For more details, see https://docs.documenso.com/docs/developers/api/migrate-to-envelopes. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.',
},
servers: [
{
@@ -526,7 +526,7 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
// Should be able to access organisation settings
await expect(page.getByText('Organisation Settings')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible();
// Should have delete permissions
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
@@ -1,284 +0,0 @@
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');
});
});
@@ -1,89 +0,0 @@
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);
});
@@ -1,70 +0,0 @@
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');
});
@@ -1,121 +0,0 @@
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);
});
@@ -20,7 +20,7 @@ import {
type TEnvelopeEditorSurface,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
import { getKonvaElementCountForPage } from '../fixtures/konva';
type TFieldFlowResult = {
externalId: string;
@@ -46,7 +46,6 @@ const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: str
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
await surface.root.getByTestId('toast-close').click();
}
};
@@ -99,17 +98,6 @@ 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()}`;
@@ -772,106 +760,9 @@ 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);
@@ -924,16 +815,6 @@ 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);
@@ -986,21 +867,6 @@ 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',
@@ -1078,22 +944,6 @@ 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',
@@ -19,7 +19,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
});
// Wait for the form to load.
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Change the amount to 2.
const amountInput = page.getByTestId('envelope-expiration-amount');
@@ -35,7 +35,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
await unitTrigger.click();
await page.getByRole('option', { name: 'Weeks' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify via database.
@@ -57,14 +57,14 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
redirectPath: `/o/${organisation.url}/settings/document`,
});
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// Find the mode select (shows "Custom duration") and change to "Never expires".
const modeTrigger = page.getByTestId('envelope-expiration-mode');
await modeTrigger.click();
await page.getByRole('option', { name: 'Never expires' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify via database.
@@ -109,7 +109,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
redirectPath: `/t/${team.url}/settings/document`,
});
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
// The expiration picker mode select should show "Inherit from organisation" by default.
const modeTrigger = page.getByTestId('envelope-expiration-mode');
@@ -128,7 +128,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
await unitTrigger.click();
await page.getByRole('option', { name: 'Days' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Verify team setting is overridden.
@@ -1,228 +0,0 @@
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { PDF } from '@libpdf/core';
import { expect, type Page, test } from '@playwright/test';
import { DocumentStatus, type Field } from '@prisma/client';
import { apiCreateTestContext, apiSeedDraftDocument, apiSeedPendingDocument } from '../fixtures/api-seeds';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
type TeamDocumentSettings = {
includeAuditLog?: boolean;
includeSigningCertificate?: boolean;
};
const updateTeamDocumentSettings = async (teamId: number, data: TeamDocumentSettings) => {
const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({
where: {
team: {
id: teamId,
},
},
});
await prisma.teamGlobalSettings.update({
where: {
id: teamSettings.id,
},
data,
});
};
const getFirstEnvelopeItemSignedPageCount = async (envelopeId: string, token: string) => {
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: {
envelopeId,
},
});
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem,
token,
version: 'signed',
});
const response = await fetch(documentUrl);
expect(response.ok).toBe(true);
const pdfData = await response.arrayBuffer();
const pdfDoc = await PDF.load(new Uint8Array(pdfData));
return pdfDoc.getPageCount();
};
const completeSigning = async ({
page,
signingUrl,
recipientToken,
field,
}: {
page: Page;
signingUrl: string;
recipientToken: string;
field: Pick<Field, 'positionX' | 'positionY' | 'width' | 'height'>;
}) => {
await page.goto(signingUrl);
// V2 envelopes render fields on a Konva canvas — wait for the PDF and canvas.
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
await signSignaturePad(page);
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const x = (Number(field.positionX) / 100) * canvasBox.width + ((Number(field.width) / 100) * canvasBox.width) / 2;
const y =
(Number(field.positionY) / 100) * canvasBox.height + ((Number(field.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/complete$/);
await expect(async () => {
const { status } = await getDocumentByToken({
token: recipientToken,
});
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass();
};
test.describe('Document audit log embedding', () => {
test('new documents derive audit-log embedding from team settings', async ({ request }) => {
const context = await apiCreateTestContext('e2e-audit-log-default');
await updateTeamDocumentSettings(context.team.id, {
includeAuditLog: true,
});
const { envelope } = await apiSeedDraftDocument(request, {
context,
});
expect(envelope.documentMeta.includeAuditLog).toBe(true);
await updateTeamDocumentSettings(context.team.id, {
includeAuditLog: false,
});
const documentMeta = await prisma.documentMeta.findFirstOrThrow({
where: {
id: envelope.documentMeta.id,
},
});
expect(documentMeta.includeAuditLog).toBe(true);
});
test('meta.includeAuditLog true appends audit-log pages when team default is false', async ({ page, request }) => {
const context = await apiCreateTestContext('e2e-audit-log-override-true');
await updateTeamDocumentSettings(context.team.id, {
includeAuditLog: false,
includeSigningCertificate: false,
});
const { distributeResult, envelope } = await apiSeedPendingDocument(request, {
context,
meta: {
includeAuditLog: true,
},
});
expect(envelope.documentMeta.includeAuditLog).toBe(true);
const signer = distributeResult.recipients[0];
const signatureField = envelope.fields.find((field) => field.recipientId === signer.id);
if (!signatureField) {
throw new Error('Signature field not found');
}
const baselinePageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
await completeSigning({
page,
signingUrl: signer.signingUrl,
recipientToken: signer.token,
field: signatureField,
});
await expect(async () => {
const signedPageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
// The audit log may span multiple pages depending on the number of
// audit events — assert growth rather than an exact page count.
expect(signedPageCount).toBeGreaterThan(baselinePageCount);
}).toPass();
});
test('meta.includeAuditLog false skips embedded audit logs when team default is true', async ({ page, request }) => {
const context = await apiCreateTestContext('e2e-audit-log-override-false');
await updateTeamDocumentSettings(context.team.id, {
includeAuditLog: true,
includeSigningCertificate: false,
});
const { distributeResult, envelope, team, user } = await apiSeedPendingDocument(request, {
context,
meta: {
includeAuditLog: false,
},
});
expect(envelope.documentMeta.includeAuditLog).toBe(false);
const signer = distributeResult.recipients[0];
const signatureField = envelope.fields.find((field) => field.recipientId === signer.id);
if (!signatureField) {
throw new Error('Signature field not found');
}
const baselinePageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
await completeSigning({
page,
signingUrl: signer.signingUrl,
recipientToken: signer.token,
field: signatureField,
});
await expect(async () => {
const signedPageCount = await getFirstEnvelopeItemSignedPageCount(envelope.id, signer.token);
expect(signedPageCount).toBe(baselinePageCount);
}).toPass();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents/${envelope.id}/logs`,
});
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download Audit Logs' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('Audit Logs.pdf');
});
});
@@ -324,7 +324,10 @@ test.describe('Signing Certificate Tests', () => {
.click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page
.getByRole('button', { name: /Update/ })
.first()
.click();
await page.waitForTimeout(1000);
@@ -344,7 +347,10 @@ test.describe('Signing Certificate Tests', () => {
.getByRole('combobox')
.click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page
.getByRole('button', { name: /Update/ })
.first()
.click();
await page.waitForTimeout(1000);
-32
View File
@@ -16,35 +16,3 @@ 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 },
);
};
@@ -60,7 +60,7 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
await page.getByLabel('Organisation URL*').clear();
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
await page.getByRole('button', { name: 'Save changes' }).click();
await page.getByRole('button', { name: 'Update organisation' }).click();
// Check we have been redirected to the new organisation URL and the name is updated.
await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`);
@@ -1,406 +0,0 @@
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();
});
});
@@ -39,7 +39,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByRole('option', { name: 'No' }).click();
await page.getByTestId('include-signing-certificate-trigger').click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
@@ -73,7 +73,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByTestId('document-date-format-trigger').click();
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
@@ -128,7 +128,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
@@ -150,7 +150,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails');
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
@@ -165,7 +165,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
// Test inheritance by setting team back to inherit from organisation
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(2000);
@@ -208,7 +208,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('checkbox', { name: 'Email the signer if the document is still pending' }).uncheck();
await page.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' }).uncheck();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
@@ -245,7 +245,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck();
await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
@@ -292,7 +292,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
await page.waitForTimeout(1000);
@@ -1,72 +0,0 @@
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);
});
@@ -66,7 +66,7 @@ test('[TEAMS]: update team', async ({ page }) => {
await page.getByLabel('Team URL*').clear();
await page.getByLabel('Team URL*').fill(updatedTeamId);
await page.getByRole('button', { name: 'Save changes' }).click();
await page.getByRole('button', { name: 'Update team' }).click();
// Check we have been redirected to the new team URL and the name is updated.
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
@@ -1,163 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe.configure({ mode: 'parallel' });
/**
* Calls a team-group tRPC mutation directly, bypassing the UI.
*
* The UI only ever surfaces CUSTOM / INTERNAL_ORGANISATION groups, so these
* authorisation rules must be enforced on the server - a crafted request can
* target any `teamGroupId`, including the system-managed INTERNAL_TEAM groups.
*/
const callTeamGroupMutation = (
page: Page,
procedure: 'team.group.delete' | 'team.group.update',
teamId: number,
input: Record<string, unknown>,
) =>
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
data: JSON.stringify({ json: input }),
});
/**
* Every team is created with three system-managed INTERNAL_TEAM groups
* (admin/manager/member). They are the backbone of team-specific access and,
* like organisation internal groups, must not be deletable - deleting them
* silently strips team members of access while leaving the team row in place.
*/
test('[TEAMS]: internal team groups cannot be deleted via the API', async ({ page }) => {
// Member inheritance OFF: membership is granted exclusively through the team's
// INTERNAL_TEAM groups, so removing them is what causes the access loss.
const { user: owner, team } = await seedUser({ inheritMembers: false });
// A direct team member whose access depends on the INTERNAL_TEAM member group.
const directMember = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await apiSignin({ page, email: owner.email });
const internalTeamGroups = await prisma.teamGroup.findMany({
where: {
teamId: team.id,
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
},
});
// admin + manager + member.
expect(internalTeamGroups).toHaveLength(3);
for (const group of internalTeamGroups) {
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
teamId: team.id,
teamGroupId: group.id,
});
expect(response.status(), `INTERNAL_TEAM ${group.teamRole} group must not be deletable`).not.toBe(200);
}
// None of the internal groups were removed.
const remaining = await prisma.teamGroup.count({
where: {
teamId: team.id,
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
},
});
expect(remaining).toBe(3);
// The direct member therefore keeps their team access.
const memberStillHasAccess = await prisma.teamGroup.findFirst({
where: {
teamId: team.id,
organisationGroup: {
type: OrganisationGroupType.INTERNAL_TEAM,
organisationGroupMembers: {
some: { organisationMember: { userId: directMember.id } },
},
},
},
});
expect(memberStillHasAccess).not.toBeNull();
});
/**
* Guards against over-blocking: user-created (CUSTOM) team groups are not
* internal and must remain removable by team managers/admins.
*/
test('[TEAMS]: custom team groups can still be deleted', async ({ page }) => {
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
const customGroup = await prisma.organisationGroup.create({
data: {
id: generateDatabaseId('org_group'),
name: `custom-${team.url}`,
type: OrganisationGroupType.CUSTOM,
organisationRole: OrganisationMemberRole.MEMBER,
organisationId: organisation.id,
teamGroups: {
create: {
id: generateDatabaseId('team_group'),
teamId: team.id,
teamRole: TeamMemberRole.MEMBER,
},
},
},
include: { teamGroups: true },
});
const customTeamGroup = customGroup.teamGroups[0];
await apiSignin({ page, email: owner.email });
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
teamId: team.id,
teamGroupId: customTeamGroup.id,
});
expect(response.status()).toBe(200);
const deleted = await prisma.teamGroup.findUnique({ where: { id: customTeamGroup.id } });
expect(deleted).toBeNull();
});
/**
* The same root cause affects updates: an INTERNAL_TEAM group's role must not be
* editable either, otherwise a team admin could rewrite the backbone roles
* (e.g. promote the member group to admin).
*/
test('[TEAMS]: internal team groups cannot be updated via the API', async ({ page }) => {
const { user: owner, team } = await seedUser({ inheritMembers: false });
await apiSignin({ page, email: owner.email });
const internalMemberGroup = await prisma.teamGroup.findFirstOrThrow({
where: {
teamId: team.id,
teamRole: TeamMemberRole.MEMBER,
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
},
});
const response = await callTeamGroupMutation(page, 'team.group.update', team.id, {
id: internalMemberGroup.id,
data: { teamRole: TeamMemberRole.ADMIN },
});
expect(response.status()).not.toBe(200);
const reloaded = await prisma.teamGroup.findUniqueOrThrow({ where: { id: internalMemberGroup.id } });
expect(reloaded.teamRole).toBe(TeamMemberRole.MEMBER);
});

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