mirror of
https://github.com/documenso/documenso.git
synced 2026-06-26 22:32:07 +10:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08e5acd0b2 | |||
| 1b55b3f7ea | |||
| 96ab78c33f | |||
| 241929bb97 | |||
| 94adea149d |
+1
-1
@@ -103,7 +103,7 @@ NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso"
|
||||
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
|
||||
|
||||
# [[SMTP]]
|
||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | resend | mailchannels
|
||||
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
||||
# OPTIONAL: Defines the host to use for sending emails.
|
||||
NEXT_PRIVATE_SMTP_HOST="127.0.0.1"
|
||||
|
||||
@@ -34,7 +34,7 @@ body:
|
||||
label: Browser [e.g., Chrome, Firefox]
|
||||
- type: input
|
||||
attributes:
|
||||
label: Version [e.g., 2.0.1]
|
||||
label: Version [e.g., 2.13.0]
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Please check the boxes that apply to this issue report.
|
||||
@@ -44,4 +44,3 @@ body:
|
||||
- label: I have included relevant environment information.
|
||||
- label: I have included any relevant screenshots.
|
||||
- label: I understand that this is a voluntary contribution and that there is no guarantee of resolution.
|
||||
- label: I want to work on creating a PR for this issue if approved
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/documenso/documenso/security/advisories/new
|
||||
about: Please report security vulnerabilities privately via GitHub Security Advisories. Do not open a public issue.
|
||||
- name: Questions & Discussions
|
||||
url: https://github.com/documenso/documenso/discussions
|
||||
about: Ask questions, share ideas, and discuss Documenso with the community.
|
||||
- name: Discord
|
||||
url: https://documen.so/discord
|
||||
about: Chat with the community and the team.
|
||||
@@ -33,4 +33,3 @@ body:
|
||||
- label: I have explained the use case or scenario for this feature.
|
||||
- label: I have included any relevant technical details or design suggestions.
|
||||
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
|
||||
- label: I want to work on creating a PR for this issue if approved
|
||||
|
||||
@@ -15,17 +15,6 @@ body:
|
||||
description: 'Are there any additional context or information that might be relevant to the improvement suggestion.'
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: assignee
|
||||
attributes:
|
||||
label: 'Do you want to work on this improvement?'
|
||||
multiple: false
|
||||
options:
|
||||
- 'No'
|
||||
- 'Yes'
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: 'Please check the boxes that apply to this improvement suggestion.'
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
<!--
|
||||
We are no longer accepting external pull requests.
|
||||
|
||||
Aside from a small group of trusted contributors we reach out to directly,
|
||||
new external PRs will usually be closed with a request to open an issue instead.
|
||||
This is a security decision. See https://documenso.com/blog/why-we-re-pausing-external-pull-requests
|
||||
|
||||
If you're a trusted contributor or maintainer, continue below.
|
||||
Otherwise, please open a detailed issue: https://github.com/documenso/documenso/issues/new/choose
|
||||
-->
|
||||
|
||||
## Description
|
||||
|
||||
<!--- Describe the changes introduced by this pull request. -->
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: Test Addition
|
||||
about: Submit a new test, either unit or end-to-end (E2E), for review and inclusion
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!--- Provide a clear and concise description of the new test you are adding. -->
|
||||
<!--- Explain the purpose of the test and what it aims to validate. -->
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!--- If this test addition is related to a specific issue, reference it here using #issue_number. -->
|
||||
<!--- For example, "Fixes #123" or "Addresses #456". -->
|
||||
|
||||
## Test Details
|
||||
|
||||
<!--- Describe the details of the test you're adding. -->
|
||||
<!--- Include information about inputs, expected outputs, and any specific scenarios. -->
|
||||
|
||||
- Test Name: Name of the test
|
||||
- Type: [Unit / E2E]
|
||||
- Description: Brief description of what the test checks
|
||||
- Inputs: What inputs the test uses (if applicable)
|
||||
- Expected Output: What output or behavior the test expects
|
||||
|
||||
## Checklist
|
||||
|
||||
<!--- Please check the boxes that apply to this pull request. -->
|
||||
<!--- You can add or remove items as needed. -->
|
||||
|
||||
- [ ] I have written the new test and ensured it works as intended.
|
||||
- [ ] I have added necessary documentation to explain the purpose of the test.
|
||||
- [ ] I have followed the project's testing guidelines and coding style.
|
||||
- [ ] I have addressed any review feedback from previous submissions, if applicable.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
<!--- Provide any additional context or notes for the reviewers. -->
|
||||
<!--- This might include explanations about the testing approach or any potential concerns. -->
|
||||
@@ -1,13 +1,10 @@
|
||||
name: 'Welcome New Contributors'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: ['opened']
|
||||
issues:
|
||||
types: ['opened']
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
@@ -20,10 +17,7 @@ jobs:
|
||||
- uses: actions/first-interaction@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
pr-message: |
|
||||
Thank you for creating your first Pull Request and for being a part of the open signing revolution! 💚🚀
|
||||
<br /> Feel free to hop into our community in [Discord](https://documen.so/discord)
|
||||
issue-message: |
|
||||
Thank you for opening your first issue and for being a part of the open signing revolution!
|
||||
<br /> One of our team members will review it and get back to you as soon as it possible 💚
|
||||
<br /> One of our team members will review it and get back to you as soon as possible 💚
|
||||
<br /> Meanwhile, please feel free to hop into our community in [Discord](https://documen.so/discord)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
name: 'Issue Assignee Check'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: ['assigned']
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
countIssues:
|
||||
if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install Octokit
|
||||
run: npm install @octokit/rest@18
|
||||
|
||||
- name: Check Assigned User's Issue Count
|
||||
id: parse-comment
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { Octokit } = require("@octokit/rest");
|
||||
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
|
||||
const username = context.payload.issue.assignee.login;
|
||||
console.log(`Username Extracted: ${username}`);
|
||||
|
||||
const { data: issues } = await octokit.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
assignee: username,
|
||||
state: 'open'
|
||||
});
|
||||
|
||||
const issueCount = issues.length;
|
||||
console.log(`Issue Count For ${username}: ${issueCount}`);
|
||||
|
||||
if (issueCount > 3) {
|
||||
let issueCountMessage = `### 🚨 Documenso Police 🚨`;
|
||||
issueCountMessage += `\n@${username} has ${issueCount} open issues assigned already. Consider whether this issue should be assigned to them or left open for another contributor.`;
|
||||
|
||||
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: issueCountMessage,
|
||||
headers: {
|
||||
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
name: 'PR Review Reminder'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: ['opened', 'ready_for_review']
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
checkPRs:
|
||||
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install Octokit
|
||||
run: npm install @octokit/rest@18
|
||||
|
||||
- name: Check user's PRs awaiting review
|
||||
id: parse-prs
|
||||
uses: actions/github-script@v5
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { Octokit } = require("@octokit/rest");
|
||||
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
|
||||
const username = context.payload.pull_request.user.login;
|
||||
console.log(`Username Extracted: ${username}`);
|
||||
|
||||
const { data: pullRequests } = await octokit.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
sort: 'created',
|
||||
direction: 'asc',
|
||||
});
|
||||
|
||||
const userPullRequests = pullRequests.filter(pr => pr.user.login === username && (pr.state === 'open' || pr.state === 'ready_for_review'));
|
||||
const prCount = userPullRequests.length;
|
||||
console.log(`PR Count for ${username}: ${prCount}`);
|
||||
|
||||
if (prCount > 3) {
|
||||
let prReminderMessage = `🚨 @${username} has ${prCount} pull requests awaiting review. Please consider reviewing them when possible. 🚨`;
|
||||
|
||||
await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: prReminderMessage,
|
||||
headers: {
|
||||
'Authorization': `token ${{ secrets.GITHUB_TOKEN }}`,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -16,24 +16,6 @@ jobs:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR creator's previous activity
|
||||
id: check_activity
|
||||
run: |
|
||||
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
|
||||
ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
|
||||
if [ "$ACTIVITY" -eq 0 ]; then
|
||||
echo "::set-output name=is_new::true"
|
||||
else
|
||||
echo "::set-output name=is_new::false"
|
||||
fi
|
||||
|
||||
- name: Count PRs created by user
|
||||
id: count_prs
|
||||
run: |
|
||||
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
|
||||
PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
|
||||
echo "::set-output name=pr_count::$PR_COUNT"
|
||||
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
id: lint_pr_title
|
||||
env:
|
||||
@@ -44,8 +26,6 @@ jobs:
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Hey There! and thank you for opening this pull request! 📝👋🏼
|
||||
|
||||
We require pull request titles to follow the [Conventional Commits Spec](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
|
||||
|
||||
Details:
|
||||
@@ -53,10 +33,3 @@ jobs:
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}}
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Thank you for following the naming conventions for pull request titles! 💚🚀
|
||||
|
||||
@@ -21,4 +21,4 @@ jobs:
|
||||
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
|
||||
close-pr-message: 'This PR has been closed because of inactivity.'
|
||||
exempt-pr-labels: 'WIP,on-hold,needs review'
|
||||
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage'
|
||||
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,status: assigned,status: triage'
|
||||
|
||||
+1
-2
@@ -31,8 +31,7 @@ vscode:
|
||||
extensions:
|
||||
- aaron-bond.better-comments
|
||||
- bradlc.vscode-tailwindcss
|
||||
- dbaeumer.vscode-eslint
|
||||
- esbenp.prettier-vscode
|
||||
- biomejs.biome
|
||||
- mikestead.dotenv
|
||||
- unifiedjs.vscode-mdx
|
||||
- GitHub.vscode-pull-request-github
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# General Issues
|
||||
# Report security vulnerabilities privately via GitHub Security Advisories (preferred).
|
||||
Contact: https://github.com/documenso/documenso/security/advisories/new
|
||||
|
||||
# Alternatively, report critical issues privately by email.
|
||||
Contact: mailto:security@documenso.com
|
||||
|
||||
# Security policy
|
||||
Policy: https://github.com/documenso/documenso/security/policy
|
||||
|
||||
# General (non-security) issues
|
||||
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
|
||||
|
||||
# Report critical issues privately to let us take appropriate action before publishing.
|
||||
Contact: mailto:security@documenso.com
|
||||
Preferred-Languages: en
|
||||
Canonical: https://documenso.com/.well-known/security.txt
|
||||
Canonical: https://documenso.com/.well-known/security.txt
|
||||
|
||||
+20
-6
@@ -1,13 +1,27 @@
|
||||
# Contributing to Documenso
|
||||
|
||||
If you plan to contribute to Documenso, please take a moment to feel awesome ✨ People like you are what open source is about ♥. Any contributions, no matter how big or small, are highly appreciated.
|
||||
> **We are no longer accepting external pull requests.**
|
||||
>
|
||||
> Aside from a small group of trusted contributors we reach out to directly, we no longer merge external PRs. New pull requests will usually be closed with a request to open an issue instead. This is a security decision, not a judgement on your work. Read [Why We're Pausing External Pull Requests](https://documenso.com/blog/why-we-re-pausing-external-pull-requests) for the full reasoning.
|
||||
>
|
||||
> Documenso stays open source. You can still read, audit, run, and fork the code. The best way to contribute is through detailed issues.
|
||||
|
||||
## Before getting started
|
||||
## How to contribute now
|
||||
|
||||
- Before jumping into a PR be sure to search [existing PRs](https://github.com/documenso/documenso/pulls) or [issues](https://github.com/documenso/documenso/issues) for an open or closed item that relates to your submission.
|
||||
- Select an issue from [here](https://github.com/documenso/documenso/issues) or create a new one
|
||||
- Consider the results from the discussion on the issue
|
||||
- Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
|
||||
The most useful contribution is a detailed issue. Treat it like a spec. The more detail, the better:
|
||||
|
||||
- The problem you're trying to solve, and who it affects
|
||||
- How you expect the feature or change to behave
|
||||
- Edge cases, constraints, and anything you've already considered
|
||||
- Examples, mockups, or references where they help
|
||||
|
||||
Before opening an issue, search [existing issues](https://github.com/documenso/documenso/issues) and [discussions](https://github.com/documenso/documenso/discussions) for related items. If a proposal is detailed and fits where Documenso is heading, we'll pick it up and build against it.
|
||||
|
||||
For security vulnerabilities, do not open a public issue. Follow our [Security Policy](./SECURITY.md) instead.
|
||||
|
||||
---
|
||||
|
||||
The sections below are for trusted contributors working with us directly, and for anyone running Documenso locally or maintaining a fork.
|
||||
|
||||
## English only PRs and Issues
|
||||
|
||||
|
||||
@@ -51,16 +51,18 @@ Join us in creating the next generation of open trust infrastructure.
|
||||
|
||||
## Community and Next Steps 🎯
|
||||
|
||||
- Check out the first source code release in this repository and test it.
|
||||
- Try Documenso by self-hosting it or signing up at [documenso.com](https://documenso.com).
|
||||
- Tell us what you think in the [Discussions](https://github.com/documenso/documenso/discussions).
|
||||
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members.
|
||||
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know other community members.
|
||||
- ⭐ the repository to help us raise awareness.
|
||||
- Spread the word on Twitter that Documenso is working towards a more open signing tool.
|
||||
- Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release.
|
||||
- Open detailed [issues](https://github.com/documenso/documenso/issues) to report bugs or propose features.
|
||||
|
||||
## Contributing
|
||||
|
||||
- To contribute, please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
|
||||
> **Note**: We no longer accept external pull requests, aside from a small group of trusted contributors we reach out to directly. The best way to contribute is through detailed issues. Read [Why We're Pausing External Pull Requests](https://documenso.com/blog/why-we-re-pausing-external-pull-requests) for the reasoning.
|
||||
|
||||
- Documenso stays open source. You can read, audit, run, and fork the code.
|
||||
- To report issues or propose changes, see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## Contact us
|
||||
|
||||
@@ -81,17 +83,21 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
||||
<a href=""><img src="" alt=""></a>
|
||||
</p>
|
||||
|
||||
- [Typescript](https://www.typescriptlang.org/) - Language
|
||||
- [ReactRouter](https://reactrouter.com/) - Framework
|
||||
- [TypeScript](https://www.typescriptlang.org/) - Language
|
||||
- [React Router v7](https://reactrouter.com/) - Framework
|
||||
- [Hono](https://hono.dev/) - Server
|
||||
- [Prisma](https://www.prisma.io/) - ORM
|
||||
- [Tailwind](https://tailwindcss.com/) - CSS
|
||||
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - CSS
|
||||
- [shadcn/ui](https://ui.shadcn.com/) + [Radix UI](https://www.radix-ui.com/) - Component Library
|
||||
- [react-email](https://react.email/) - Email Templates
|
||||
- [Lingui](https://lingui.dev/) - Internationalization
|
||||
- [tRPC](https://trpc.io/) - API
|
||||
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures (launching soon)
|
||||
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||
- [@libpdf/core](https://www.npmjs.com/package/@libpdf/core) - PDF Signatures
|
||||
- [pdf.js](https://mozilla.github.io/pdf.js/) - Viewing PDFs
|
||||
- [@cantoo/pdf-lib](https://github.com/cantoo-scribe/pdf-lib) - PDF manipulation
|
||||
- [Stripe](https://stripe.com/) - Payments
|
||||
- [Biome](https://biomejs.dev/) - Linting & Formatting
|
||||
- [Playwright](https://playwright.dev/) - E2E Testing
|
||||
|
||||
<!-- - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. -->
|
||||
|
||||
@@ -196,6 +202,10 @@ For full instructions, requirements, and configuration details, see the [Self Ho
|
||||
|
||||
[](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
@@ -0,0 +1,38 @@
|
||||
# Security Policy
|
||||
|
||||
We take the security of Documenso seriously. As a platform trusted with legally binding documents, the safety of the project and the people who rely on it is a priority for us. We're grateful to the security researchers who help keep it that way. If you've found an issue, we'd genuinely like to hear about it.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Report security vulnerabilities privately. Do not open a public issue, discussion, or pull request for security reports.
|
||||
|
||||
We accept reports through two channels, in order of preference:
|
||||
|
||||
1. **GitHub Security Advisories (preferred)**. Use the [private vulnerability reporting form](https://github.com/documenso/documenso/security/advisories/new). This is our primary channel and lets us triage and work with you on a fix.
|
||||
2. **Email**. If you cannot use GitHub Security Advisories, email [security@documenso.com](mailto:security@documenso.com).
|
||||
|
||||
Include the affected version, a clear description, steps to reproduce, and the potential impact.
|
||||
|
||||
## Triage and Response
|
||||
|
||||
We triage reports as we have availability. We read every report we receive, and we appreciate the time and effort it takes to put one together.
|
||||
|
||||
We also run [Codex](https://openai.com/codex/) security analysis across the codebase. If Codex has already reported the issue you're sending us, we may close your report as a duplicate. Please don't take this as a reflection on your work; it just means our automated tooling happened to surface the same thing first.
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers vulnerabilities in the Documenso application code in this repository.
|
||||
|
||||
The items below are out of scope and will not be accepted. They are deployment, infrastructure, and configuration concerns that belong with the operator's firewall, network, and environment setup, not the application:
|
||||
|
||||
- Server-Side Request Forgery (SSRF) and related network-egress concerns
|
||||
- DNS rebinding and other DNS-level issues
|
||||
- Rate limiting, denial of service, and volumetric attacks
|
||||
- TLS and certificate configuration, HTTP security headers, and other reverse-proxy or web-server configuration
|
||||
- Findings that depend on insecure self-hosted infrastructure or misconfiguration
|
||||
|
||||
If you're unsure whether something is in scope, report it privately anyway and we'll happily take a look.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security fixes are applied to the latest release. Run the most recent version of Documenso.
|
||||
+6
-64
@@ -1,67 +1,9 @@
|
||||
# Creating your own signing certificate
|
||||
# Signing Certificate
|
||||
|
||||
For the digital signature of your documents you need a signing certificate in .p12 format (public and private key). You can buy one (not recommended for dev) or use the steps to create a self-signed one:
|
||||
Documenso needs a signing certificate to digitally sign documents. For full, up-to-date instructions on generating, converting, and configuring a certificate, see the official documentation:
|
||||
|
||||
1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key:
|
||||
- [Signing Certificate](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate): Overview and all certificate options
|
||||
- [Local Certificate](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/local): Generate a self-signed `.p12` certificate with OpenSSL
|
||||
- [Google Cloud HSM](https://docs.documenso.com/docs/self-hosting/configuration/signing-certificate/google-cloud-hsm): Sign using Google Cloud KMS
|
||||
|
||||
`openssl genrsa -out private.key 2048`
|
||||
|
||||
2. Generate a self-signed certificate using the private key. You can run the following command to generate a self-signed certificate:
|
||||
|
||||
`openssl req -new -x509 -key private.key -out certificate.crt -days 365`
|
||||
|
||||
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The `-days` parameter sets the number of days for which the certificate is valid.
|
||||
|
||||
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following commands to do this:
|
||||
|
||||
```bash
|
||||
# Set certificate password securely (won't appear in command history)
|
||||
read -s -p "Enter certificate password: " CERT_PASS
|
||||
echo
|
||||
|
||||
# Create the p12 certificate using the environment variable
|
||||
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt \
|
||||
-password env:CERT_PASS \
|
||||
-keypbe PBE-SHA1-3DES \
|
||||
-certpbe PBE-SHA1-3DES \
|
||||
-macalg sha1
|
||||
```
|
||||
|
||||
4. **IMPORTANT**: A certificate password is required to prevent signing failures. Make sure to use a strong password (minimum 4 characters) when prompted. Certificates without passwords will cause "Failed to get private key bags" errors during document signing.
|
||||
|
||||
5. Place the certificate `/apps/remix/resources/certificate.p12` (If the path does not exist, it needs to be created)
|
||||
|
||||
## Docker
|
||||
|
||||
> We are still working on the publishing of docker images, in the meantime you can follow the steps below to create a production ready docker image.
|
||||
|
||||
Want to create a production ready docker image? Follow these steps:
|
||||
|
||||
- cd into `docker` directory
|
||||
- Make `build.sh` executable by running `chmod +x build.sh`
|
||||
- Run `./build.sh` to start building the docker image.
|
||||
- Publish the image to your docker registry of choice (or) If you prefer running the image from local, run the below command
|
||||
|
||||
```
|
||||
docker run -d --restart=unless-stopped -p 3000:3000 -v documenso:/app/data --name documenso documenso:latest
|
||||
```
|
||||
|
||||
Command Breakdown:
|
||||
|
||||
- `-d` - Let's you run the container in background
|
||||
- `-p` - Passes down which ports to use. First half is the host port, Second half is the app port. You can change the first half anything you want and reverse proxy to that port.
|
||||
- `-v` - Volume let's you persist the data
|
||||
- `--name` - Name of the container
|
||||
- `documenso:latest` - Image you have built
|
||||
|
||||
## Deployment
|
||||
|
||||
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
|
||||
|
||||
## Railway
|
||||
|
||||
[](https://railway.com/deploy/DjrRRX?referralCode=EZR3s0&utm_medium=integration&utm_source=template&utm_campaign=generic)
|
||||
|
||||
## Render
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||
For deploying Documenso with Docker, see the [Docker Deployment](https://docs.documenso.com/docs/self-hosting/deployment/docker) and [Docker Compose](https://docs.documenso.com/docs/self-hosting/deployment/docker-compose) guides.
|
||||
|
||||
+9
-38
@@ -1,45 +1,16 @@
|
||||
# docs
|
||||
# @documenso/docs
|
||||
|
||||
This is a Next.js application generated with
|
||||
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
|
||||
The Documenso documentation site, built with [Next.js](https://nextjs.org/) and [Fumadocs](https://fumadocs.dev/). Published at [docs.documenso.com](https://docs.documenso.com).
|
||||
|
||||
Run development server:
|
||||
Content lives under `content/docs/` as MDX. See [WRITING_STYLE.md](../../WRITING_STYLE.md) for the documentation writing conventions.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
yarn dev
|
||||
# From the monorepo root
|
||||
npm run dev --filter=@documenso/docs
|
||||
```
|
||||
|
||||
Open http://localhost:3000 with your browser to see the result.
|
||||
## Structure
|
||||
|
||||
## Explore
|
||||
|
||||
In the project, you can see:
|
||||
|
||||
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
|
||||
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
|
||||
|
||||
| Route | Description |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `app/(home)` | The route group for your landing page and other pages. |
|
||||
| `app/docs` | The documentation layout and pages. |
|
||||
| `app/api/search/route.ts` | The Route Handler for search. |
|
||||
|
||||
### Fumadocs MDX
|
||||
|
||||
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
|
||||
|
||||
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js and Fumadocs, take a look at the following
|
||||
resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
|
||||
features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs
|
||||
- `content/docs/`: Documentation pages (MDX).
|
||||
- `lib/source.ts`: Content source adapter.
|
||||
- `lib/layout.shared.tsx`: Shared layout options.
|
||||
|
||||
@@ -15,16 +15,17 @@ Pick the one that fits your needs the best.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- [Typescript](https://www.typescriptlang.org/) - Language
|
||||
- [React Router](https://reactrouter.com/) - Framework
|
||||
- [TypeScript](https://www.typescriptlang.org/) - Language
|
||||
- [React Router v7](https://reactrouter.com/) - Framework
|
||||
- [Hono](https://hono.dev/) - Server
|
||||
- [Prisma](https://www.prisma.io/) - ORM
|
||||
- [Tailwind](https://tailwindcss.com/) - CSS
|
||||
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - CSS
|
||||
- [shadcn/ui](https://ui.shadcn.com/) + [Radix UI](https://www.radix-ui.com/) - Component Library
|
||||
- [react-email](https://react.email/) - Email Templates
|
||||
- [Lingui](https://lingui.dev/) - Internationalization
|
||||
- [tRPC](https://trpc.io/) - API
|
||||
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
|
||||
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||
- [@libpdf/core](https://www.npmjs.com/package/@libpdf/core) - PDF Signing and Manipulation
|
||||
- [pdf.js](https://mozilla.github.io/pdf.js/) - Viewing PDFs
|
||||
- [Stripe](https://stripe.com/) - Payments
|
||||
|
||||
<div className="mt-16 flex items-center justify-center gap-4">
|
||||
|
||||
@@ -278,7 +278,9 @@ Test your email configuration by creating an account or resetting a password. Th
|
||||
|
||||
### Using a Test SMTP Server
|
||||
|
||||
For development or testing, use a local SMTP server like [Mailhog](https://github.com/mailhog/MailHog) or [Mailpit](https://github.com/axllent/mailpit):
|
||||
For development or testing, use a local SMTP server like [Inbucket](https://www.inbucket.org/), [Mailpit](https://github.com/axllent/mailpit), or [Mailhog](https://github.com/mailhog/MailHog). The default development setup (`docker/development/compose.yml`) already runs Inbucket, with its web UI on port 9000 and SMTP on port 2500.
|
||||
|
||||
To run one standalone instead:
|
||||
|
||||
```bash
|
||||
# Using Docker
|
||||
|
||||
@@ -86,6 +86,21 @@ Callback URL: `https://<your-domain>/api/auth/callback/microsoft`
|
||||
| `NEXT_PRIVATE_OIDC_SKIP_VERIFY` | `false` | Skip email verification for OIDC accounts |
|
||||
| `NEXT_PRIVATE_OIDC_PROMPT` | `login` | OIDC prompt parameter. Set to empty string to omit |
|
||||
|
||||
### Webhooks
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --------------------------------------- | ------- | ------------------------------------------------------------------------ |
|
||||
| `NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` | - | Comma-separated hostnames or IPs allowed to resolve to private addresses |
|
||||
|
||||
Before delivering a webhook, Documenso checks whether the target resolves to a
|
||||
private or loopback address and blocks it if so. This check is best-effort and
|
||||
fails open. Use `NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS` to allow specific
|
||||
internal hosts, for example when delivering to a service on your own network:
|
||||
|
||||
```bash
|
||||
NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS="hooks.internal.example,10.0.0.5"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Email Configuration
|
||||
|
||||
@@ -235,7 +235,7 @@ spec:
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
Pin to a specific image tag (e.g., `documenso/documenso:1.5.0`) in production instead of `latest`
|
||||
Pin to a specific image tag (e.g., `documenso/documenso:<version>`) in production instead of `latest`
|
||||
to ensure predictable deployments.
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20 or later
|
||||
- npm
|
||||
- Node.js 22 or later
|
||||
- npm 11 or later
|
||||
- PostgreSQL 14 or later
|
||||
- A Linux server (for systemd service setup)
|
||||
|
||||
|
||||
@@ -124,12 +124,16 @@ docker compose -f docker/development/compose.yml exec database \
|
||||
|
||||
The quick start setup runs the following containers:
|
||||
|
||||
| Container | Purpose | Port |
|
||||
| ----------- | -------------------------------- | ----- |
|
||||
| `documenso` | Main application | 3000 |
|
||||
| `database` | PostgreSQL database | 54320 |
|
||||
| `maildev` | Local email testing server | 2500 |
|
||||
| `minio` | S3-compatible storage (optional) | 9000 |
|
||||
| Container | Purpose | Port |
|
||||
| ----------- | ------------------------------------ | ----------------------------- |
|
||||
| `documenso` | Main application | 3000 |
|
||||
| `database` | PostgreSQL database | 54320 |
|
||||
| `inbucket` | Local email testing server | 9000 (web UI), 2500 (SMTP) |
|
||||
| `redis` | Cache and background job queue | 63790 |
|
||||
| `minio` | S3-compatible storage | 9002 (API), 9001 (console) |
|
||||
| `gotenberg` | Document conversion (optional) | 3005 |
|
||||
|
||||
The local email server is [Inbucket](https://www.inbucket.org/). Open its web UI at [http://localhost:9000](http://localhost:9000) to view emails Documenso sends during development. For your own deployment you can use any SMTP-compatible mailserver, such as Inbucket, [Mailpit](https://github.com/axllent/mailpit), or [Mailhog](https://github.com/mailhog/MailHog).
|
||||
|
||||
## Useful Commands
|
||||
|
||||
|
||||
@@ -141,8 +141,8 @@ If building from source (not using Docker images):
|
||||
|
||||
| Requirement | Version |
|
||||
| ----------- | ------- |
|
||||
| Node.js | 18+ |
|
||||
| npm | 8+ |
|
||||
| Node.js | 22+ |
|
||||
| npm | 11+ |
|
||||
|
||||
---
|
||||
|
||||
@@ -169,7 +169,7 @@ Documenso runs on:
|
||||
| MySQL/MariaDB | PostgreSQL-specific features required |
|
||||
| SQLite | Not suitable for production workloads |
|
||||
| MongoDB | Relational database required |
|
||||
| Node.js < 18 | Modern JavaScript features required |
|
||||
| Node.js < 22 | Modern JavaScript features required |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ Use a specific version tag in production:
|
||||
|
||||
```bash
|
||||
# Good — predictable, reproducible
|
||||
docker pull documenso/documenso:1.8.0
|
||||
docker pull documenso/documenso:<version>
|
||||
|
||||
# Risky — may pull breaking changes
|
||||
docker pull documenso/documenso:latest
|
||||
|
||||
@@ -27,6 +27,14 @@ import { Callout } from 'fumadocs-ui/components/callout';
|
||||
Please see all the [requirements](/docs/self-hosting/getting-started/requirements) before proceeding.
|
||||
</Callout>
|
||||
|
||||
<Callout type="warn">
|
||||
**You are responsible for your own network security.** Documenso applies best-effort, non-exhaustive
|
||||
checks to outbound requests such as webhooks, but these are not a complete SSRF mitigation and they
|
||||
fail open. A self-hosted instance can reach internal addresses on your network. Restricting outbound
|
||||
traffic, egress filtering, and blocking access to internal services and cloud metadata endpoints is
|
||||
your responsibility through your firewall and network configuration.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Deployment Options
|
||||
|
||||
@@ -165,10 +165,10 @@ See [Backups](/docs/self-hosting/maintenance/backups) for automated backup strat
|
||||
### Pull the new image
|
||||
|
||||
```bash
|
||||
docker pull documenso/documenso:1.6.0
|
||||
docker pull documenso/documenso:<version>
|
||||
```
|
||||
|
||||
Replace `1.6.0` with your target version.
|
||||
Replace `<version>` with your target version.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
@@ -189,7 +189,7 @@ docker run -d \
|
||||
-p 3000:3000 \
|
||||
--env-file .env \
|
||||
-v /path/to/cert.p12:/opt/documenso/cert.p12:ro \
|
||||
documenso/documenso:1.6.0
|
||||
documenso/documenso:<version>
|
||||
```
|
||||
|
||||
</Step>
|
||||
@@ -223,14 +223,14 @@ Edit `compose.yml` or your `.env` file to specify the new version:
|
||||
```yaml
|
||||
services:
|
||||
documenso:
|
||||
image: documenso/documenso:1.6.0
|
||||
image: documenso/documenso:<version>
|
||||
```
|
||||
|
||||
Or if using environment variable substitution:
|
||||
|
||||
```bash
|
||||
# In .env
|
||||
DOCUMENSO_VERSION=1.6.0
|
||||
DOCUMENSO_VERSION=<version>
|
||||
```
|
||||
|
||||
```yaml
|
||||
@@ -283,7 +283,7 @@ Edit the deployment directly:
|
||||
|
||||
```bash
|
||||
kubectl set image deployment/documenso \
|
||||
documenso=documenso/documenso:1.6.0 \
|
||||
documenso=documenso/documenso:<version> \
|
||||
-n documenso
|
||||
```
|
||||
|
||||
@@ -295,7 +295,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: documenso
|
||||
image: documenso/documenso:1.6.0
|
||||
image: documenso/documenso:<version>
|
||||
```
|
||||
|
||||
Then apply:
|
||||
@@ -421,12 +421,12 @@ To run migrations manually before upgrading:
|
||||
|
||||
```bash
|
||||
# Pull the new image
|
||||
docker pull documenso/documenso:1.6.0
|
||||
docker pull documenso/documenso:<version>
|
||||
|
||||
# Run migrations only
|
||||
docker run --rm \
|
||||
-e NEXT_PRIVATE_DATABASE_URL="postgresql://user:password@host:5432/documenso" \
|
||||
documenso/documenso:1.6.0 \
|
||||
documenso/documenso:<version> \
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
@@ -516,7 +516,7 @@ docker run -d \
|
||||
-p 3000:3000 \
|
||||
--env-file .env \
|
||||
-v /path/to/cert.p12:/opt/documenso/cert.p12:ro \
|
||||
documenso/documenso:1.5.0
|
||||
documenso/documenso:<previous-version>
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
FROM node:20-alpine AS development-dependencies-env
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS production-dependencies-env
|
||||
COPY ./package.json package-lock.json /app/
|
||||
WORKDIR /app
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:20-alpine AS build-env
|
||||
COPY . /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
COPY ./package.json package-lock.json /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["npm", "run", "start"]
|
||||
+7
-93
@@ -1,100 +1,14 @@
|
||||
# Welcome to React Router!
|
||||
# @documenso/remix
|
||||
|
||||
A modern, production-ready template for building full-stack React applications using React Router.
|
||||
The main Documenso web application. Built with [React Router v7](https://reactrouter.com/) and served by a [Hono](https://hono.dev/) server.
|
||||
|
||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||
This package is part of the Documenso monorepo and is not meant to be run standalone. Use the root scripts instead.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Server-side rendering
|
||||
- ⚡️ Hot Module Replacement (HMR)
|
||||
- 📦 Asset bundling and optimization
|
||||
- 🔄 Data loading and mutations
|
||||
- 🔒 TypeScript by default
|
||||
- 🎉 TailwindCSS for styling
|
||||
- 📖 [React Router docs](https://reactrouter.com/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server with HMR:
|
||||
- Local development: see the [root README](../../README.md) and the [Local Development docs](https://docs.documenso.com/docs/developers/local-development).
|
||||
- Self-hosting and deployment: see the [Self-Hosting docs](https://docs.documenso.com/docs/self-hosting).
|
||||
- Architecture overview: see [ARCHITECTURE.md](../../ARCHITECTURE.md).
|
||||
|
||||
```bash
|
||||
# From the monorepo root
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your application will be available at `http://localhost:5173`.
|
||||
|
||||
## Building for Production
|
||||
|
||||
Create a production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
This template includes three Dockerfiles optimized for different package managers:
|
||||
|
||||
- `Dockerfile` - for npm
|
||||
- `Dockerfile.pnpm` - for pnpm
|
||||
- `Dockerfile.bun` - for bun
|
||||
|
||||
To build and run using Docker:
|
||||
|
||||
```bash
|
||||
# For npm
|
||||
docker build -t my-app .
|
||||
|
||||
# For pnpm
|
||||
docker build -f Dockerfile.pnpm -t my-app .
|
||||
|
||||
# For bun
|
||||
docker build -f Dockerfile.bun -t my-app .
|
||||
|
||||
# Run the container
|
||||
docker run -p 3000:3000 my-app
|
||||
```
|
||||
|
||||
The containerized application can be deployed to any platform that supports Docker, including:
|
||||
|
||||
- AWS ECS
|
||||
- Google Cloud Run
|
||||
- Azure Container Apps
|
||||
- Digital Ocean App Platform
|
||||
- Fly.io
|
||||
- Railway
|
||||
|
||||
### DIY Deployment
|
||||
|
||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||
|
||||
Make sure to deploy the output of `npm run build`
|
||||
|
||||
```
|
||||
├── package.json
|
||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||
├── build/
|
||||
│ ├── client/ # Static assets
|
||||
│ └── server/ # Server-side code
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ using React Router.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
|
||||
export type DocumentPreferencesResetDialogProps = {
|
||||
disabled?: boolean;
|
||||
isSubmitting: boolean;
|
||||
onReset: () => Promise<void>;
|
||||
showAiFeatures?: boolean;
|
||||
showDocumentVisibility?: boolean;
|
||||
showIncludeSenderDetails?: boolean;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DocumentPreferencesResetDialog = ({
|
||||
disabled = false,
|
||||
isSubmitting,
|
||||
onReset,
|
||||
showAiFeatures = false,
|
||||
showDocumentVisibility = false,
|
||||
showIncludeSenderDetails = false,
|
||||
trigger,
|
||||
}: DocumentPreferencesResetDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const isLoading = isSubmitting || isResetting;
|
||||
|
||||
const handleResetToDefaults = async () => {
|
||||
setIsResetting(true);
|
||||
|
||||
try {
|
||||
await onReset();
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="destructive" type="button" disabled={disabled || isLoading}>
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Reset document preferences</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
This will reset all document preferences to their default values and save the changes immediately.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>Once confirmed, the following will be reset:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="mt-0.5 list-inside list-disc">
|
||||
{showDocumentVisibility && (
|
||||
<li>
|
||||
<Trans>Default document visibility</Trans>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<Trans>Default document language</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default date format</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default time zone</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default signature settings</Trans>
|
||||
</li>
|
||||
{showIncludeSenderDetails && (
|
||||
<li>
|
||||
<Trans>Send on behalf of team</Trans>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<Trans>Include the signing certificate in the document</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Include the audit logs in the document</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default recipients</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Delegate document ownership</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default envelope expiration</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Default signing reminders</Trans>
|
||||
</li>
|
||||
{showAiFeatures && (
|
||||
<li>
|
||||
<Trans>AI features</Trans>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isLoading}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
|
||||
<Trans>Reset to defaults</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -11,10 +11,10 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
|
||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||
import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta';
|
||||
import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
|
||||
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
|
||||
@@ -38,11 +38,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg, t } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
|
||||
@@ -93,6 +93,26 @@ export type DocumentPreferencesFormProps = {
|
||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => {
|
||||
const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat);
|
||||
|
||||
return {
|
||||
documentVisibility: settings.documentVisibility,
|
||||
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
|
||||
documentTimezone: settings.documentTimezone,
|
||||
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
|
||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||
reminderSettings: settings.reminderSettings ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export const DocumentPreferencesForm = ({
|
||||
settings,
|
||||
onFormSubmit,
|
||||
@@ -113,7 +133,7 @@ export const DocumentPreferencesForm = ({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
||||
documentTimezone: z.string().nullable(),
|
||||
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
|
||||
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
|
||||
includeSenderDetails: z.boolean().nullable(),
|
||||
includeSigningCertificate: z.boolean().nullable(),
|
||||
includeAuditLog: z.boolean().nullable(),
|
||||
@@ -127,26 +147,27 @@ export const DocumentPreferencesForm = ({
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullable(),
|
||||
});
|
||||
|
||||
const defaultValues = getDocumentPreferencesFormValues(settings);
|
||||
const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings();
|
||||
const baseResetValues = getDocumentPreferencesFormValues(defaultSettings);
|
||||
const resetValues = {
|
||||
...baseResetValues,
|
||||
aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled,
|
||||
};
|
||||
|
||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
documentVisibility: settings.documentVisibility,
|
||||
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
|
||||
documentTimezone: settings.documentTimezone,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
|
||||
delegateDocumentOwnership: settings.delegateDocumentOwnership,
|
||||
aiFeaturesEnabled: settings.aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
|
||||
reminderSettings: settings.reminderSettings ?? null,
|
||||
},
|
||||
defaultValues,
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const currentValues = form.watch();
|
||||
const isResetDisabled = !form.formState.isDirty && JSON.stringify(currentValues) === JSON.stringify(resetValues);
|
||||
|
||||
const handleResetToDefaults = async () => {
|
||||
await onFormSubmit(resetValues);
|
||||
form.reset(resetValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
@@ -760,6 +781,14 @@ export const DocumentPreferencesForm = ({
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
<DocumentPreferencesResetDialog
|
||||
disabled={isResetDisabled}
|
||||
isSubmitting={form.formState.isSubmitting}
|
||||
onReset={handleResetToDefaults}
|
||||
showAiFeatures={isAiFeaturesConfigured}
|
||||
showDocumentVisibility={!isPersonalLayoutMode}
|
||||
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -11,10 +11,10 @@ import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
export type DocumentAuditLogDownloadButtonProps = {
|
||||
className?: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
};
|
||||
|
||||
export const DocumentAuditLogDownloadButton = ({ className, documentId }: DocumentAuditLogDownloadButtonProps) => {
|
||||
export const DocumentAuditLogDownloadButton = ({ className, envelopeId }: DocumentAuditLogDownloadButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
@@ -22,7 +22,7 @@ export const DocumentAuditLogDownloadButton = ({ className, documentId }: Docume
|
||||
|
||||
const onDownloadAuditLogsClick = async () => {
|
||||
try {
|
||||
const { data, envelopeTitle } = await downloadAuditLogs({ documentId });
|
||||
const { data, envelopeTitle } = await downloadAuditLogs({ envelopeId });
|
||||
|
||||
const buffer = new Uint8Array(base64.decode(data));
|
||||
const blob = new Blob([buffer], { type: 'application/pdf' });
|
||||
|
||||
@@ -13,13 +13,13 @@ import { DownloadIcon } from 'lucide-react';
|
||||
|
||||
export type DocumentCertificateDownloadButtonProps = {
|
||||
className?: string;
|
||||
documentId: number;
|
||||
envelopeId: string;
|
||||
documentStatus: DocumentStatus;
|
||||
};
|
||||
|
||||
export const DocumentCertificateDownloadButton = ({
|
||||
className,
|
||||
documentId,
|
||||
envelopeId,
|
||||
documentStatus,
|
||||
}: DocumentCertificateDownloadButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
@@ -29,7 +29,7 @@ export const DocumentCertificateDownloadButton = ({
|
||||
|
||||
const onDownloadCertificatesClick = async () => {
|
||||
try {
|
||||
const { data, envelopeTitle } = await downloadCertificate({ documentId });
|
||||
const { data, envelopeTitle } = await downloadCertificate({ envelopeId });
|
||||
|
||||
const buffer = new Uint8Array(base64.decode(data));
|
||||
const blob = new Blob([buffer], { type: 'application/pdf' });
|
||||
|
||||
@@ -153,11 +153,11 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||
<DocumentCertificateDownloadButton
|
||||
className="mr-2"
|
||||
documentId={document.id}
|
||||
envelopeId={document.envelopeId}
|
||||
documentStatus={document.status}
|
||||
/>
|
||||
|
||||
<DocumentAuditLogDownloadButton documentId={document.id} />
|
||||
<DocumentAuditLogDownloadButton envelopeId={document.envelopeId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
# General Issues
|
||||
# Report security vulnerabilities privately via GitHub Security Advisories (preferred).
|
||||
Contact: https://github.com/documenso/documenso/security/advisories/new
|
||||
|
||||
# Alternatively, report critical issues privately by email.
|
||||
Contact: mailto:security@documenso.com
|
||||
|
||||
# Security policy
|
||||
Policy: https://github.com/documenso/documenso/security/policy
|
||||
|
||||
# General (non-security) issues
|
||||
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
|
||||
|
||||
# Report critical issues privately to let us take appropriate action before publishing.
|
||||
Contact: mailto:security@documenso.com
|
||||
Preferred-Languages: en
|
||||
Canonical: https://documenso.com/.well-known/security.txt
|
||||
Canonical: https://documenso.com/.well-known/security.txt
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getEnvelopeById, getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
|
||||
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
|
||||
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import type { HonoEnv } from '../../router';
|
||||
import { handleEnvelopeItemFileRequest } from '../files/files.helpers';
|
||||
import {
|
||||
ZDownloadDocumentRequestParamsSchema,
|
||||
ZDownloadEnvelopeAuditLogPdfRequestParamsSchema,
|
||||
ZDownloadEnvelopeCertificatePdfRequestParamsSchema,
|
||||
ZDownloadEnvelopeItemRequestParamsSchema,
|
||||
ZDownloadEnvelopeItemRequestQuerySchema,
|
||||
} from './download.types';
|
||||
|
||||
/**
|
||||
* Resolve and validate an API token from the Authorization header.
|
||||
*
|
||||
* Supports both "Authorization: Bearer api_xxx" and "Authorization: api_xxx".
|
||||
*/
|
||||
const resolveApiToken = async (authorizationHeader: string | undefined) => {
|
||||
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'API token was not provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await getApiTokenByToken({ token });
|
||||
|
||||
if (apiToken.user.disabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User is disabled',
|
||||
});
|
||||
}
|
||||
|
||||
return apiToken;
|
||||
};
|
||||
|
||||
export const downloadRoute = new Hono<HonoEnv>()
|
||||
/**
|
||||
* Download an envelope item by its ID.
|
||||
@@ -30,24 +62,8 @@ export const downloadRoute = new Hono<HonoEnv>()
|
||||
try {
|
||||
const { envelopeItemId } = c.req.valid('param');
|
||||
const { version } = c.req.valid('query');
|
||||
const authorizationHeader = c.req.header('authorization');
|
||||
|
||||
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
|
||||
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'API token was not provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await getApiTokenByToken({ token });
|
||||
|
||||
if (apiToken.user.disabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User is disabled',
|
||||
});
|
||||
}
|
||||
const apiToken = await resolveApiToken(c.req.header('authorization'));
|
||||
|
||||
logger.info({
|
||||
auth: 'api',
|
||||
@@ -125,6 +141,180 @@ export const downloadRoute = new Hono<HonoEnv>()
|
||||
}
|
||||
},
|
||||
)
|
||||
/**
|
||||
* Download the audit log for a document as a PDF.
|
||||
* Requires API key authentication via Authorization header.
|
||||
*/
|
||||
.get(
|
||||
'/envelope/:envelopeId/audit-log/pdf',
|
||||
sValidator('param', ZDownloadEnvelopeAuditLogPdfRequestParamsSchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
try {
|
||||
const { envelopeId } = c.req.valid('param');
|
||||
|
||||
const apiToken = await resolveApiToken(c.req.header('authorization'));
|
||||
|
||||
logger.info({
|
||||
auth: 'api',
|
||||
source: 'apiV2',
|
||||
path: c.req.path,
|
||||
userId: apiToken.user.id,
|
||||
apiTokenId: apiToken.id,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
const envelope = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: apiToken.user.id,
|
||||
teamId: apiToken.teamId,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Document not found' }, 404);
|
||||
}
|
||||
|
||||
const auditLogPdf = await generateAuditLogPdf({
|
||||
envelope,
|
||||
recipients: envelope.recipients,
|
||||
fields: envelope.fields,
|
||||
language: envelope.documentMeta.language,
|
||||
envelopeOwner: {
|
||||
email: envelope.user.email,
|
||||
name: envelope.user.name || '',
|
||||
},
|
||||
envelopeItems: envelope.envelopeItems.map((item) => item.title),
|
||||
pageWidth: PDF_SIZE_A4_72PPI.width,
|
||||
pageHeight: PDF_SIZE_A4_72PPI.height,
|
||||
});
|
||||
|
||||
const result = await auditLogPdf.save();
|
||||
|
||||
const baseTitle = envelope.title.replace(/\.pdf$/i, '');
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
c.header('Content-Disposition', contentDisposition(`${baseTitle}_audit-log.pdf`));
|
||||
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
|
||||
return c.body(result);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
|
||||
if (error instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json({ error: body.message, code: error.code }, status);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
},
|
||||
)
|
||||
/**
|
||||
* Download the signing certificate for a completed document as a PDF.
|
||||
* Requires API key authentication via Authorization header.
|
||||
*/
|
||||
.get(
|
||||
'/envelope/:envelopeId/certificate/pdf',
|
||||
sValidator('param', ZDownloadEnvelopeCertificatePdfRequestParamsSchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
try {
|
||||
const { envelopeId } = c.req.valid('param');
|
||||
|
||||
const apiToken = await resolveApiToken(c.req.header('authorization'));
|
||||
|
||||
logger.info({
|
||||
auth: 'api',
|
||||
source: 'apiV2',
|
||||
path: c.req.path,
|
||||
userId: apiToken.user.id,
|
||||
apiTokenId: apiToken.id,
|
||||
envelopeId,
|
||||
});
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: apiToken.user.id,
|
||||
teamId: apiToken.teamId,
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: envelopeWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
fields: {
|
||||
include: {
|
||||
signature: true,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Document not found' }, 404);
|
||||
}
|
||||
|
||||
// A cancelled document was never sealed/completed, so a signing certificate
|
||||
// must not be generated for it.
|
||||
if (!isDocumentCompleted(envelope.status) || envelope.status === DocumentStatus.CANCELLED) {
|
||||
throw new AppError('DOCUMENT_NOT_COMPLETE', {
|
||||
message: 'Document is not complete',
|
||||
});
|
||||
}
|
||||
|
||||
const certificatePdf = await generateCertificatePdf({
|
||||
envelope,
|
||||
recipients: envelope.recipients,
|
||||
fields: envelope.fields,
|
||||
language: envelope.documentMeta.language,
|
||||
envelopeOwner: {
|
||||
email: envelope.user.email,
|
||||
name: envelope.user.name || '',
|
||||
},
|
||||
pageWidth: PDF_SIZE_A4_72PPI.width,
|
||||
pageHeight: PDF_SIZE_A4_72PPI.height,
|
||||
});
|
||||
|
||||
const result = await certificatePdf.save();
|
||||
|
||||
const baseTitle = envelope.title.replace(/\.pdf$/i, '');
|
||||
|
||||
c.header('Content-Type', 'application/pdf');
|
||||
c.header('Content-Disposition', contentDisposition(`${baseTitle}_certificate.pdf`));
|
||||
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
|
||||
return c.body(result);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
|
||||
if (error instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json({ error: body.message, code: error.code }, status);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
},
|
||||
)
|
||||
/**
|
||||
* Download a document by its ID.
|
||||
* Requires API key authentication via Authorization header.
|
||||
@@ -134,24 +324,8 @@ export const downloadRoute = new Hono<HonoEnv>()
|
||||
|
||||
try {
|
||||
const { documentId, version } = c.req.valid('param');
|
||||
const authorizationHeader = c.req.header('authorization');
|
||||
|
||||
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
|
||||
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'API token was not provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await getApiTokenByToken({ token });
|
||||
|
||||
if (apiToken.user.disabled) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User is disabled',
|
||||
});
|
||||
}
|
||||
const apiToken = await resolveApiToken(c.req.header('authorization'));
|
||||
|
||||
logger.info({
|
||||
auth: 'api',
|
||||
@@ -200,11 +374,9 @@ export const downloadRoute = new Hono<HonoEnv>()
|
||||
logger.error(error);
|
||||
|
||||
if (error instanceof AppError) {
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
return c.json({ error: error.message }, 401);
|
||||
}
|
||||
const { status, body } = AppError.toRestAPIError(error);
|
||||
|
||||
return c.json({ error: error.message }, 400);
|
||||
return c.json({ error: body.message, code: error.code }, status);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
|
||||
@@ -30,3 +30,17 @@ export const ZDownloadDocumentRequestParamsSchema = z.object({
|
||||
});
|
||||
|
||||
export type TDownloadDocumentRequestParams = z.infer<typeof ZDownloadDocumentRequestParamsSchema>;
|
||||
|
||||
export const ZDownloadEnvelopeAuditLogPdfRequestParamsSchema = z.object({
|
||||
envelopeId: z.string().describe('The ID of the envelope to download the audit log for.'),
|
||||
});
|
||||
|
||||
export type TDownloadEnvelopeAuditLogPdfRequestParams = z.infer<typeof ZDownloadEnvelopeAuditLogPdfRequestParamsSchema>;
|
||||
|
||||
export const ZDownloadEnvelopeCertificatePdfRequestParamsSchema = z.object({
|
||||
envelopeId: z.string().describe('The ID of the envelope to download the certificate for.'),
|
||||
});
|
||||
|
||||
export type TDownloadEnvelopeCertificatePdfRequestParams = z.infer<
|
||||
typeof ZDownloadEnvelopeCertificatePdfRequestParamsSchema
|
||||
>;
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"with:env": "dotenv -e .env -e .env.local --",
|
||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
|
||||
"precommit": "npm install && git add package.json package-lock.json",
|
||||
"trigger:dev": "npm run with:env -- npx trigger-cli dev --handler-path=\"/api/jobs\"",
|
||||
"inngest:dev": "inngest dev -u http://localhost:3000/api/jobs",
|
||||
"make:version": "npm version --workspace @documenso/remix --include-workspace-root --no-git-tag-version -m \"v%s\"",
|
||||
"translate": "npm run translate:extract && npm run translate:compile",
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { OrganisationGroupType, type OrganisationMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
/**
|
||||
* Calls a tRPC mutation directly using the cookies of whoever is currently
|
||||
* signed in on the page context. This deliberately bypasses the UI: the
|
||||
* authorisation checks under test live on the server, and the UI may simply
|
||||
* hide a button rather than reject the request, which would mask a backend gap.
|
||||
*/
|
||||
const trpcMutation = async (page: Page, procedure: string, input: Record<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();
|
||||
});
|
||||
});
|
||||
@@ -71,10 +71,17 @@ const isBypassedHost = (url: string): boolean => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that a webhook URL does not resolve to a private or loopback
|
||||
* address. Throws an AppError with WEBHOOK_INVALID_REQUEST if it does.
|
||||
* Assert that a webhook URL does not point at a private/loopback address,
|
||||
* checking both the literal host and its resolved DNS records. Throws an
|
||||
* AppError with WEBHOOK_INVALID_REQUEST if it does. Hosts listed in
|
||||
* NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
|
||||
*
|
||||
* Hosts listed in NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
|
||||
* This is best-effort, non-exhaustive SSRF defence, NOT a complete mitigation.
|
||||
* It does not cover DNS rebinding (the resolved address can change between this
|
||||
* check and the actual request), obscure IP encodings, or every IPv6 form, and
|
||||
* it fails open on lookup errors/timeouts (see the catch below). Network-level
|
||||
* SSRF protection (firewall/egress rules, blocking internal services and cloud
|
||||
* metadata endpoints) remains the responsibility of the deployment.
|
||||
*/
|
||||
export const assertNotPrivateUrl = async (
|
||||
url: string,
|
||||
|
||||
@@ -3,10 +3,11 @@ import { z } from 'zod';
|
||||
const ZIpSchema = z.string().ip();
|
||||
|
||||
/**
|
||||
* Check whether a URL points to a known private/loopback address.
|
||||
* Synchronously check whether a URL's host is a known private/loopback address
|
||||
* (localhost, RFC 1918, link-local, loopback, etc.), regardless of protocol.
|
||||
*
|
||||
* Performs a synchronous check against known private hostnames and IP ranges.
|
||||
* Works regardless of the URL protocol.
|
||||
* Best-effort and non-exhaustive: unrecognised or unparseable hosts return
|
||||
* `false` (fail open). See `assertNotPrivateUrl` for the full SSRF caveats.
|
||||
*/
|
||||
export const isPrivateUrl = (url: string): boolean => {
|
||||
try {
|
||||
|
||||
@@ -15,18 +15,18 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure
|
||||
.output(ZDownloadDocumentAuditLogsResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const { documentId } = input;
|
||||
const { envelopeId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
documentId,
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
const envelope = await getEnvelopeById({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: ctx.user.id,
|
||||
@@ -55,10 +55,8 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure
|
||||
|
||||
const result = await certificatePdf.save();
|
||||
|
||||
const base64 = Buffer.from(result).toString('base64');
|
||||
|
||||
return {
|
||||
data: base64,
|
||||
data: Buffer.from(result).toString('base64'),
|
||||
envelopeTitle: envelope.title,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDownloadDocumentAuditLogsRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
envelopeId: z.string(),
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentAuditLogsResponseSchema = z.object({
|
||||
|
||||
@@ -17,18 +17,18 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
|
||||
.output(ZDownloadDocumentCertificateResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const { documentId } = input;
|
||||
const { envelopeId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
documentId,
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
id: {
|
||||
type: 'documentId',
|
||||
id: documentId,
|
||||
type: 'envelopeId',
|
||||
id: envelopeId,
|
||||
},
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
userId: ctx.user.id,
|
||||
@@ -81,10 +81,8 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure
|
||||
|
||||
const result = await certificatePdf.save();
|
||||
|
||||
const base64 = Buffer.from(result).toString('base64');
|
||||
|
||||
return {
|
||||
data: base64,
|
||||
data: Buffer.from(result).toString('base64'),
|
||||
envelopeTitle: envelope.title,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDownloadDocumentCertificateRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
envelopeId: z.string(),
|
||||
});
|
||||
|
||||
export const ZDownloadDocumentCertificateResponseSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
downloadEnvelopeAuditLogPdfMeta,
|
||||
ZDownloadEnvelopeAuditLogPdfRequestSchema,
|
||||
ZDownloadEnvelopeAuditLogPdfResponseSchema,
|
||||
} from './download-envelope-audit-log-pdf.types';
|
||||
|
||||
export const downloadEnvelopeAuditLogPdfRoute = authenticatedProcedure
|
||||
.meta(downloadEnvelopeAuditLogPdfMeta)
|
||||
.input(ZDownloadEnvelopeAuditLogPdfRequestSchema)
|
||||
.output(ZDownloadEnvelopeAuditLogPdfResponseSchema)
|
||||
.query(({ input, ctx }) => {
|
||||
const { envelopeId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
// This endpoint is purely for V2 API, which is implemented in the Hono remix server.
|
||||
throw new Error('NOT_IMPLEMENTED');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const downloadEnvelopeAuditLogPdfMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/envelope/{envelopeId}/audit-log/pdf',
|
||||
summary: 'Download envelope audit log PDF',
|
||||
description: 'Download the audit log for a document as a PDF.',
|
||||
tags: ['Envelope'],
|
||||
responseHeaders: z.object({
|
||||
'Content-Type': z.literal('application/pdf'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ZDownloadEnvelopeAuditLogPdfRequestSchema = z.object({
|
||||
envelopeId: z.string().describe('The ID of the envelope to download the audit log for.'),
|
||||
});
|
||||
|
||||
export const ZDownloadEnvelopeAuditLogPdfResponseSchema = z.instanceof(Uint8Array);
|
||||
|
||||
export type TDownloadEnvelopeAuditLogPdfRequest = z.infer<typeof ZDownloadEnvelopeAuditLogPdfRequestSchema>;
|
||||
export type TDownloadEnvelopeAuditLogPdfResponse = z.infer<typeof ZDownloadEnvelopeAuditLogPdfResponseSchema>;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
downloadEnvelopeCertificatePdfMeta,
|
||||
ZDownloadEnvelopeCertificatePdfRequestSchema,
|
||||
ZDownloadEnvelopeCertificatePdfResponseSchema,
|
||||
} from './download-envelope-certificate-pdf.types';
|
||||
|
||||
export const downloadEnvelopeCertificatePdfRoute = authenticatedProcedure
|
||||
.meta(downloadEnvelopeCertificatePdfMeta)
|
||||
.input(ZDownloadEnvelopeCertificatePdfRequestSchema)
|
||||
.output(ZDownloadEnvelopeCertificatePdfResponseSchema)
|
||||
.query(({ input, ctx }) => {
|
||||
const { envelopeId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
},
|
||||
});
|
||||
|
||||
// This endpoint is purely for V2 API, which is implemented in the Hono remix server.
|
||||
throw new Error('NOT_IMPLEMENTED');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const downloadEnvelopeCertificatePdfMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/envelope/{envelopeId}/certificate/pdf',
|
||||
summary: 'Download envelope certificate PDF',
|
||||
description: 'Download the signing certificate for a completed document as a PDF.',
|
||||
tags: ['Envelope'],
|
||||
responseHeaders: z.object({
|
||||
'Content-Type': z.literal('application/pdf'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ZDownloadEnvelopeCertificatePdfRequestSchema = z.object({
|
||||
envelopeId: z.string().describe('The ID of the envelope to download the certificate for.'),
|
||||
});
|
||||
|
||||
export const ZDownloadEnvelopeCertificatePdfResponseSchema = z.instanceof(Uint8Array);
|
||||
|
||||
export type TDownloadEnvelopeCertificatePdfRequest = z.infer<typeof ZDownloadEnvelopeCertificatePdfRequestSchema>;
|
||||
export type TDownloadEnvelopeCertificatePdfResponse = z.infer<typeof ZDownloadEnvelopeCertificatePdfResponseSchema>;
|
||||
@@ -12,6 +12,8 @@ import { createEnvelopeItemsRoute } from './create-envelope-items';
|
||||
import { deleteEnvelopeRoute } from './delete-envelope';
|
||||
import { deleteEnvelopeItemRoute } from './delete-envelope-item';
|
||||
import { distributeEnvelopeRoute } from './distribute-envelope';
|
||||
import { downloadEnvelopeAuditLogPdfRoute } from './download-envelope-audit-log-pdf';
|
||||
import { downloadEnvelopeCertificatePdfRoute } from './download-envelope-certificate-pdf';
|
||||
import { downloadEnvelopeItemRoute } from './download-envelope-item';
|
||||
import { duplicateEnvelopeRoute } from './duplicate-envelope';
|
||||
import { createEnvelopeFieldsRoute } from './envelope-fields/create-envelope-fields';
|
||||
@@ -85,6 +87,10 @@ export const envelopeRouter = router({
|
||||
find: findEnvelopesRoute,
|
||||
auditLog: {
|
||||
find: findEnvelopeAuditLogsRoute,
|
||||
downloadPdf: downloadEnvelopeAuditLogPdfRoute,
|
||||
},
|
||||
certificate: {
|
||||
downloadPdf: downloadEnvelopeCertificatePdfRoute,
|
||||
},
|
||||
bulk: {
|
||||
move: bulkMoveEnvelopesRoute,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
|
||||
import { buildOrganisationWhereQuery, isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationGroupType } from '@prisma/client';
|
||||
|
||||
@@ -59,6 +60,22 @@ export const deleteOrganisationGroupRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const currentUserOrganisationRole = await getMemberOrganisationRole({
|
||||
organisationId,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// A user cannot delete a group whose role is higher than their own
|
||||
// (e.g. a manager deleting an admin-role group).
|
||||
if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, group.organisationRole)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You are not allowed to delete this organisation group',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisationGroup.delete({
|
||||
where: {
|
||||
id: groupId,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import {
|
||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import {
|
||||
buildOrganisationWhereQuery,
|
||||
getHighestOrganisationRoleInGroup,
|
||||
isOrganisationRoleWithinUserHierarchy,
|
||||
} from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
|
||||
|
||||
@@ -60,9 +67,12 @@ export const deleteOrganisationMembers = async ({
|
||||
},
|
||||
},
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
include: {
|
||||
organisationGroupMembers: {
|
||||
include: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invites: {
|
||||
@@ -84,6 +94,41 @@ export const deleteOrganisationMembers = async ({
|
||||
|
||||
const membersToDelete = organisation.members.filter((member) => organisationMemberIds.includes(member.id));
|
||||
|
||||
const currentUserMember = organisation.members.find((member) => member.userId === userId);
|
||||
|
||||
if (!currentUserMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const currentUserOrganisationRole = getHighestOrganisationRoleInGroup(
|
||||
currentUserMember.organisationGroupMembers.map(({ group }) => group),
|
||||
);
|
||||
|
||||
// The roles the current user is allowed to act on (their own role and below).
|
||||
const manageableOrganisationRoles = ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserOrganisationRole];
|
||||
|
||||
for (const member of membersToDelete) {
|
||||
// The organisation owner can never be removed via this route. Ownership must
|
||||
// be transferred first (mirrors the admin and update-member routes).
|
||||
if (member.userId === organisation.ownerUserId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Cannot remove the organisation owner',
|
||||
});
|
||||
}
|
||||
|
||||
const memberOrganisationRole = getHighestOrganisationRoleInGroup(
|
||||
member.organisationGroupMembers.map(({ group }) => group),
|
||||
);
|
||||
|
||||
// A user cannot remove a member whose role is higher than their own
|
||||
// (e.g. a manager removing an admin).
|
||||
if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, memberOrganisationRole)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Cannot remove a member with a higher role',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const inviteCount = organisation.invites.length;
|
||||
const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length;
|
||||
|
||||
@@ -123,6 +168,18 @@ export const deleteOrganisationMembers = async ({
|
||||
in: organisationMemberIds,
|
||||
},
|
||||
organisationId,
|
||||
userId: {
|
||||
not: organisation.ownerUserId,
|
||||
},
|
||||
organisationGroupMembers: {
|
||||
none: {
|
||||
group: {
|
||||
organisationRole: {
|
||||
notIn: manageableOrganisationRoles,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,14 @@ export const leaveOrganisationRoute = authenticatedProcedure
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
// The organisation owner cannot leave their own organisation. Ownership must
|
||||
// be transferred to another member first.
|
||||
if (organisation.ownerUserId === userId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You cannot leave an organisation you own. Please transfer ownership first.',
|
||||
});
|
||||
}
|
||||
|
||||
const { organisationClaim } = organisation;
|
||||
|
||||
const inviteCount = organisation.invites.length;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { sendOrganisationMemberInviteEmail } from '@documenso/lib/server-only/organisation/create-organisation-member-invites';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
|
||||
import { buildOrganisationWhereQuery, isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
@@ -97,6 +98,21 @@ export const resendOrganisationMemberInvitation = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const currentUserOrganisationRole = await getMemberOrganisationRole({
|
||||
organisationId: organisation.id,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// A user cannot interact with an invitation that is not within their own hierarchy.
|
||||
if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, invitation.organisationRole)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You cannot resend an invite for a member with a higher role',
|
||||
});
|
||||
}
|
||||
|
||||
await sendOrganisationMemberInviteEmail({
|
||||
email: invitation.email,
|
||||
token: invitation.token,
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ services:
|
||||
envVars:
|
||||
# Node Version
|
||||
- key: NODE_VERSION
|
||||
value: 18.17.0
|
||||
value: 22.13.0
|
||||
|
||||
- key: PORT
|
||||
value: 10000
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./packages/tsconfig/base.json",
|
||||
"include": ["packages/**/*", "apps/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user