Compare commits

..

38 Commits

Author SHA1 Message Date
bb17bb9800 Merge branch 'main' into fix/refactor-use-template 2024-05-07 00:01:09 +07:00
e8d4fe46e5 fix: custom email message for self-signers (#1120) 2024-05-06 09:22:50 +03:00
64e3e2c64b fix: disable encrypted pdfs (#1130)
## Description

Currently if you complete a pending encrypted document, it will prevent
the document from being sealed due to the systems inability to decrypt
it.

This PR disables uploading any documents that cannot be loaded as a
temporary measure.

**Note**
This is a client side only check

## Changes Made

- Disable uploading documents that cannot be parsed
- Refactor putFile to putDocumentFile
- Add a flag as a backup incase something goes wrong
2024-05-03 22:25:24 +07:00
ec42fbcbcb fix: refactor 2024-05-01 21:40:16 +07:00
91b4bb52b5 fix: tests 2024-05-01 16:46:23 +07:00
3209ce8c78 Merge branch 'main' into fix/refactor-use-template 2024-05-01 15:16:25 +07:00
1e825ede45 fix: refactor 2024-05-01 15:14:00 +07:00
15dee5ef35 fix: enforce users to have stripe account (#1131)
## Description

Currently users who sign in via Google SSO do not get assigned a Stripe
customer account.

This enforces the Stripe customer requirement on sign in.

There might be a better place to put this so it's open to any
suggestions.
2024-05-01 16:48:05 +10:00
28d6f6e2e8 fix: improve sealing process (#1133)
## Description

Improves the sealing process by being strict on how long certificate
generation can take, opting to fail generation and continue sealing.

Also changes the ordering of sealing so an error in the process won't
also cause a document to be "COMPLETED" since it hasn't been
cryptographically sealed yet.

The downside to this change is that documents that fail during sealing
will require manual intervention as a signer or owner won't be able to
*complete* the document.

## Testing Performed

- Modified code to force specific failure modes to occur and verified
that documents were either gracefully sealed without a certificate or
not sealed and not completed.
2024-05-01 16:47:11 +10:00
78dc57a6eb fix: improvements from review 2024-05-01 16:16:04 +10:00
d3528f74f0 fix: improve sealing process
Improves the sealing process by being strict on how
long certificate generation can take, opting to fail
generation and continue sealing.

Also changes the ordering of sealing so an error in the
process won't also cause a document to be "COMPLETED"
since it hasn't been cryptographically sealed yet.

The downside to this change is that documents that fail
during sealing will require manual intervention as a signer
or owner won't be able to *complete* the document.
2024-05-01 14:18:01 +10:00
dbd452be97 fix: delete pending documents (#1118)
## Description

Currently deleting a pending document where you are a recipient off will
delete the document, but will also throw an error.

This is due to the recipient being updated after the document deleted,
which is only supposed to happen for completed documents.
2024-04-30 20:53:18 +07:00
5109bb17d6 chore: fix button styling (#1132)
**Description:**

This PR fixes the button styling issue

**Before:**


![image](https://github.com/documenso/documenso/assets/23498248/0af045aa-3714-48d8-9c22-6cd171b07079)

**After:**

<img width="1280" alt="Screenshot 2024-04-30 at 6 48 47 PM"
src="https://github.com/documenso/documenso/assets/23498248/e7dd99de-60fc-4cc2-aefc-21b130aa0116">
2024-04-30 18:57:29 +05:30
6974a76ed4 chore: fix button styling 2024-04-30 18:47:49 +05:30
5efb0894e6 chore: updated dark mode text (#1129)
Description:

This PR updates the dark mode text for this article,
https://app.documenso.com/articles/signature-disclosure
2024-04-30 17:14:28 +05:30
cfec366c1a fix: refactor 2024-04-30 15:54:24 +07:00
8622e68853 fix: add logging 2024-04-30 15:50:22 +07:00
0e16a86e74 chore: updated dark mode text 2024-04-30 11:55:01 +05:30
97d334a1da fix: force users to have a Stripe customer on sign in 2024-04-29 20:15:40 +07:00
345e42537a fix: include all document meta when using the public api 2024-04-29 12:42:22 +10:00
8a24ca2065 fix: complete document when all recipients are CC (#1113)
## Description

Automatically marks the document as completed if all the recipients are
CC.

## Changes Made

Added an if statement in the last form step (`onAddSubjectFormSubmit`)
that checks if all the recipients are CC. If so, the document status is
updated to `COMPLETED`.

## Testing Performed

Tested the changes and they work as expected.

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced document sending logic to update document status based on
recipient roles.

- **Bug Fixes**
- Removed redundant form submission handling in the document editing
feature.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-27 21:39:25 +10:00
06dd8219a5 fix: increase trpc max duration (#1121)
## Description

Increase the max duration of the TRPC API endpoint to 120 seconds.
2024-04-27 21:38:21 +10:00
74b9bc786b fix: extend 2024-04-27 18:29:52 +07:00
364c499927 fix: increase trpc max duration 2024-04-27 15:21:46 +07:00
b0ce06f6fe Merge branch 'main' into fix/doc-status-cc-role 2024-04-26 17:17:07 +07:00
a25b9a372e Merge branch 'main' into fix/refactor-use-template 2024-04-26 13:25:19 +07:00
4080806606 fix: minor updates 2024-04-26 02:17:56 +00:00
39bd3e5880 fix: enhancements 2024-04-25 22:28:41 +07:00
1e33bc2aa3 Merge branch 'main' into fix/doc-status-cc-role 2024-04-24 20:30:10 +07:00
d7959950e2 fix: edit-document line 2024-04-24 09:41:34 +03:00
bb43547a45 fix: complete document when all recipients are CC 2024-04-24 09:39:47 +03:00
3fb69422e8 Merge branch 'main' into fix/doc-status-cc-role 2024-04-23 14:26:37 +03:00
4d5365bddc fix: complete document when all recipients are CC 2024-04-23 14:24:58 +03:00
0eee570781 fix: complete document when all recipients are CC 2024-04-23 12:33:40 +03:00
ef666b0e70 fix: tests 2024-04-23 15:58:31 +07:00
9a801d6091 fix: add /api/v1 backwards compat 2024-04-23 14:42:03 +07:00
193419d169 fix: add template send 2024-04-23 13:12:42 +07:00
6cba74e128 fix: refactor use template 2024-04-22 16:05:16 +07:00
69 changed files with 552 additions and 4428 deletions

View File

@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, 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.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,33 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}

View File

@ -1,22 +0,0 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

View File

@ -1,9 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const withNextra = require('nextra')({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.tsx',
});
module.exports = withNextra(nextConfig);

View File

@ -1,29 +0,0 @@
{
"name": "@documenso/documentation",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3002",
"build": "next build",
"start": "next start -p 3002",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
"next": "14.0.3",
"nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}

View File

@ -1,7 +0,0 @@
import '../styles.css'
export default function App({ Component, pageProps }) {
return <Component {...pageProps} />
}

View File

@ -1,44 +0,0 @@
{
"index": {
"title": "Home",
"type": "page",
"theme": {
"layout": "full",
"sidebar": false
},
"display": "hidden"
},
"app": {
"title": "Getting Started",
"type": "page"
},
"faq": {
"title": "FAQ",
"type": "page"
},
"local-development": {
"title": "Local Development",
"type": "page"
},
"public-api": {
"title": "Public API",
"type": "page",
"href": "/fcc/"
},
"webhooks": {
"title": "Webhooks",
"type": "page"
},
"contributing": {
"title": "Contributing Guide",
"type": "page"
},
"---": {
"type": "separator"
},
"GitHub": {
"title": "View on GitHub",
"href": "https://documen.so/github",
"newWindow": true
}
}

View File

@ -1 +0,0 @@
# Getting Started

View File

@ -1,8 +0,0 @@
{
"index": {
"title": "Get started",
"theme": {
"sidebar": false
}
}
}

View File

@ -1,115 +0,0 @@
---
title: Contributing Guide
description: Learn how to contribute to Documenso and become part of our community.
---
import { Callout, Steps } from 'nextra/components';
# 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 🙏. This guide will help you get started with contributing to Documenso 💻.
## Before Getting Started
<Steps>
### Check the Existing Issues and Pull Requests
Search the existing [issues](https://github.com/documenso/documenso/issues) to see if someone else reported the same issue. Or, check the [existing PRs](https://github.com/documenso/documenso/pulls) to see if someone else is already working on the same thing.
### Creating a New Issue
If there is no issue or PR for the problem you are facing, feel free to create a new issue. Make sure to provide as much detail as possible, including the steps to reproduce the issue.
### Picking an Existing Issue
If you pick an existing issue, take into consideration the discussion on the issue.
### Contributor License Agreement
Accept the [Contributor License Agreement](https://documen.so/cla) to ensure we can accept your contributions.
</Steps>
## Taking Issues
Before taking an issue, ensure that:
- The issue has been assigned the public label.
- The issue is clearly defined and understood.
- No one has been assigned to the issue.
- No one has expressed the intention to work on it.
After that:
1. Comment on the issue with your intention to work on it.
2. Start working on the issue.
Feel free to ask for help, clarification or guidance if needed. We are here to help you.
## Developing
The development branch is `main`, and all pull requests should be made against this branch. Here's how you can get started with developing:
<Steps>
### Set Up Documenso Locally
Check out the [local development](/local-development) guide to set up your local environment.
### Pick a Task
Find an issue to work on or create a new one.
- Before starting to work on an issue, ensure that no one else is working on it. If no one is assigned to the issue, feel free to pick it up by leaving a comment on the issue and asking to get it assigned to you.
Before creating a new issue, check the existing issues to see if someone else has already reported it.
### Create a New Branch
After you're assigned an issue, you can start working on it. Create a new branch for your feature or bug fix.
When creating a branch, make sure that the branch name:
- starts with the correct prefix: `feat/` for new features, `fix/` for bug fixes, etc.
- includes the issue id you are working on (if applicable).
- is descriptive.
```sh
git checkout -b feat/issue-id-your-branch-name
## Example
git checkout -b feat/1234-add-share-button-to-articles
```
In the pull request description, include `references #yyyy` or `fixes #yyyy` to link it to the issue you are working on.
### Implement Your Changes
Start working on the issue you picked up and implement the changes. Make sure to test your changes locally and ensure that they work as expected.
### Open a Pull Request
After implementing your changes, open a pull request against the `main` branch.
</Steps>
<Callout type="info">
If you need help getting started, [join us on Discord](https://documen.so/discord).
</Callout>
## Building
Before pushing code or creating pull requests, please ensure you can successfully create a successful production build. You can build the project by running the following command in your terminal:
```bash
npm run build
```
Once the project builds successfully, you can push your code changes or create a pull request.
<Callout type="info">
Remember to run tests and perform any necessary checks before finalizing your changes. As a
result, we can collaborate more effectively and maintain a high standard of code quality in our
project.
</Callout>

View File

@ -1 +0,0 @@
# FAQ

View File

@ -1,78 +0,0 @@
export const Wrapper = ({ children }) => {
return <div className="p-24">{children}</div>;
};
<Wrapper>
# Documentation
Welcome to the official Documenso documentation! This documentation is designed to help you get started with Documenso.
For users, we'll guide you through setting up your Documenso account, creating and organizing your documents, collaborating with team members, and leveraging our advanced features. You'll learn how to get the most out of Documenso.
If you're a developer, you'll find detailed information on how to set up your local development environment, work with our API, and utilize webhooks for seamless integration. We'll provide code examples, best practices, and troubleshooting tips to help you effectively incorporate Documenso's features into your applications.
We aim to make our documentation clear, concise, and easy to navigate. If you have any questions, feedback, or suggestions for improving our documentation, please don't hesitate to reach out to us. We want to make your experience with Documenso as smooth and enjoyable as possible.
Let's get started!
## About Documenso
Signing documents digitally should be fast and easy and should be the best practice for every document signed worldwide.
This is technically quite easy today, but it also introduces a new party to every signature: The signing tool providers. While this is not a problem in itself, it should make us think about how we want these providers of trust to work.
Documenso aims to be the world's most trusted document-signing tool. This trust is built by empowering you to self-host Documenso and review how it works under the hood.
Join us in creating the next generation of open trust infrastructure.
## Our tech stack:
- [Typescript](https://www.typescriptlang.org/) - Language
- [Next.js](https://nextjs.org/) - Framework
- [Prisma](https://www.prisma.io/) - ORM
- [Tailwind](https://tailwindcss.com/) - CSS
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
- [NextAuth.js](https://next-auth.js.org/) - Authentication
- [react-email](https://react.email/) - Email Templates
- [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
- [Stripe](https://stripe.com/) - Payments
- [Vercel](https://vercel.com) - Hosting
<div className="mt-16 flex items-center justify-center gap-4">
<a href="https://documen.so/discord">
<img
src="https://img.shields.io/badge/Discord-documen.so/discord-%235865F2"
alt="Join Documenso on Discord"
/>
</a>
<a href="https://github.com/documenso/documenso/stargazers">
<img src="https://img.shields.io/github/stars/documenso/documenso" alt="Github Stars" />
</a>
<a href="https://github.com/documenso/documenso/blob/main/LICENSE">
<img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License" />
</a>
<a href="https://github.com/documenso/documenso/pulse">
<img
src="https://img.shields.io/github/commit-activity/m/documenso/documenso"
alt="Commits-per-month"
/>
</a>
<a href="https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso">
<img
alt="open in devcontainer"
src="https://img.shields.io/static/v1?label=Dev%20Containers&message=Enabled&color=blue&logo=visualstudiocode"
/>
</a>
<a href="https://github.com/documenso/documenso/blob/main/CODE_OF_CONDUCT.md">
<img
src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg"
alt="Contributor Covenant"
/>
</a>
</div>
</Wrapper>

View File

@ -1,7 +0,0 @@
{
"index": "Home",
"quickstart": "Developer Quickstart",
"manual": "Manual Setup",
"docker": "Docker",
"gitpod": "Gitpod"
}

View File

@ -1,177 +0,0 @@
---
title: Docker Development
description: Set up Documenso using Docker for local development.
---
import { Callout, Steps } from 'nextra/components';
# Docker
The following guide will walk you through setting up Documenso using Docker. You can choose between a Docker Compose production setup or a standalone container.
We provide a Docker container for Documenso, which is published on both DockerHub and GitHub Container Registry.
- DockerHub: [https://hub.docker.com/r/documenso/documenso](https://hub.docker.com/r/documenso/documenso)
- GitHub Container Registry: [https://ghcr.io/documenso/documenso](https://ghcr.io/documenso/documenso)
You can pull the Docker image from either of these registries and run it with your preferred container hosting provider.
Please note that you will need to provide environment variables for connecting to the database, mail server, and other services.
## Prerequisites
Before you begin, ensure that you have the following installed:
- Docker
- Docker Compose (if using the Docker Compose setup)
## Option 1: Production Docker Compose Setup
This setup includes a PostgreSQL database and the Documenso application. You will need to provide your own SMTP details using environment variables.
<Steps>
### Download the Docker Compose File
Download the Docker Compose file from the Documenso repository - [compose.yml](https://raw.githubusercontent.com/documenso/documenso/release/docker/production/compose.yml).
### Navigate to the `compose.yml` File
Once downloaded, navigate to the directory containing the `compose.yml` file.
### Set Up Environment Variables
Create a `.env` file in the same directory as the `compose.yml` file.
Then add your SMTP details as well as the following environment variables:
```bash
NEXTAUTH_SECRET="<your-secret>"
NEXT_PRIVATE_ENCRYPTION_KEY="<your-key>"
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-secondary-key>"
NEXT_PUBLIC_WEBAPP_URL="<your-url>"
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
NEXT_PRIVATE_SMTP_HOST="<your-host>"
NEXT_PRIVATE_SMTP_PORT=<your-port>
NEXT_PRIVATE_SMTP_USERNAME="<your-username>"
NEXT_PRIVATE_SMTP_PASSWORD="<your-password>"
```
### Update the Volume Binding
The `cert.p12` file is required to sign and encrypt documents, so you must provide your key file. Update the volume binding in the `compose.yml` file to point to your key file:
```yaml
volumes:
- /path/to/your/keyfile.p12:/opt/documenso/cert.p12
```
After updating the volume binding, save the `compose.yml` file and run the following command to start the containers:
```bash
docker-compose --env-file ./.env -d up
```
The command will start the PostgreSQL database and the Documenso application containers.
### Access the Application
Access the Documenso application by visiting `http://localhost:3000` in your web browser.
</Steps>
## Option 2: Standalone Docker Container
If you prefer to host the Documenso application on a specific container provider, you can use the pre-built Docker image from DockerHub or GitHub's Package Registry. Note that you will need to provide your own database and SMTP host.
<Steps>
### Pull the Docker Image
Pull the Documenso Docker image from DockerHub:
```bash
docker pull documenso/documenso
```
Or, pull the image from GitHub Container Registry:
```bash
docker pull ghcr.io/documenso/documenso
```
### Run the Docker Container
Run the Docker container with the required environment variables:
```bash
docker run -d \
-p 3000:3000 \
-e NEXTAUTH_URL="<your-nextauth-url>"
-e NEXTAUTH_SECRET="<your-nextauth-secret>"
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>"
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>"
-e NEXT_PUBLIC_WEBAPP_URL="<your-next-public-webapp-url>"
-e NEXT_PRIVATE_DATABASE_URL="<your-next-private-database-url>"
-e NEXT_PRIVATE_DIRECT_DATABASE_URL="<your-next-private-database-url>"
-e NEXT_PRIVATE_SMTP_TRANSPORT="<your-next-private-smtp-transport>"
-e NEXT_PRIVATE_SMTP_FROM_NAME="<your-next-private-smtp-from-name>"
-e NEXT_PRIVATE_SMTP_FROM_ADDRESS="<your-next-private-smtp-from-address>"
-v /path/to/your/keyfile.p12:/opt/documenso/cert.p12
documenso/documenso
```
Replace the placeholders with the actual values.
### Access the Application
Access the Documenso application by visiting the URL you provided for the `NEXT_PUBLIC_WEBAPP_URL` environment variable in your web browser.
</Steps>
## Advanced Configuration
The environment variables listed above are a subset of those available for configuring Documenso. The table below provides a complete list of environment variables and their descriptions.
| Variable | Description |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
| `NEXTAUTH_URL` | The URL for the NextAuth.js authentication service. |
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_ID` | The Google client ID for Google authentication (optional). |
| `NEXT_PRIVATE_GOOGLE_CLIENT_SECRET` | The Google client secret for Google authentication (optional). |
| `NEXT_PUBLIC_WEBAPP_URL` | The URL for the web application. |
| `NEXT_PRIVATE_DATABASE_URL` | The URL for the primary database connection (with connection pooling). |
| `NEXT_PRIVATE_DIRECT_DATABASE_URL` | The URL for the direct database connection (without connection pooling). |
| `NEXT_PRIVATE_SIGNING_TRANSPORT` | The signing transport to use. Available options: local (default) |
| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | The passphrase for the key file. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | The base64-encoded contents of the key file will be used instead of the file path. |
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY` | The secret access key for the S3 storage transport. |
| `NEXT_PRIVATE_SMTP_TRANSPORT` | The transport to send emails (smtp-auth, smtp-api, resend, or mailchannels). |
| `NEXT_PRIVATE_SMTP_HOST` | The host for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_PORT` | The port for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_USERNAME` | The username for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_PASSWORD` | The password for the SMTP server for the `smtp-auth` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY_USER` | The API key user for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_APIKEY` | The API key for the SMTP server for the `smtp-api` transport. |
| `NEXT_PRIVATE_SMTP_SECURE` | Whether to force the use of TLS for the SMTP server for SMTP transports. |
| `NEXT_PRIVATE_SMTP_FROM_ADDRESS` | The email address for the "from" address. |
| `NEXT_PRIVATE_SMTP_FROM_NAME` | The sender name for the "from" address. |
| `NEXT_PRIVATE_RESEND_API_KEY` | The API key for Resend.com for the `resend` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_API_KEY` | The optional API key for MailChannels (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_ENDPOINT` | The optional endpoint for the MailChannels API (if using a proxy) for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN` | The domain for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR` | The selector for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY` | The private key for DKIM signing with MailChannels for the `mailchannels` transport. |
| `NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT` | The maximum document upload limit displayed to the user (in MB). |
| `NEXT_PUBLIC_POSTHOG_KEY` | The optional PostHog key for analytics and feature flags. |
| `NEXT_PUBLIC_DISABLE_SIGNUP` | Whether to disable user signups through the /signup page. |

View File

@ -1,10 +0,0 @@
---
title: Run in Gitpod
description: Get started with Documenso in a ready-to-use Gitpod workspace in your browser.
---
# Run in Gitpod
Click below to launch a ready-to-use Gitpod workspace in your browser.
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/documenso/documenso)

View File

@ -1,15 +0,0 @@
---
title: Local development
description: Learn how to set up Documenso for local development.
---
# Local development
There are multiple ways of setting up Documenso for local development. At the moment of writing this documentation, there are 4 ways of running Documenso locally:
- [Using the developer quickstart](/local-development/quickstart)
- [Manually setting up the development environment](/local-development/manual)
- [Using the Docker image](/local-development/docker)
- [Using Gitpod](/local-development/gitpod)
Pick the one that fits your needs the best.

View File

@ -1,77 +0,0 @@
---
title: Manual Setup
description: Manually set up Documenso on your machine for local development.
---
import { Callout, Steps } from 'nextra/components';
# Manual Setup
Follow these steps to set up Documenso on your local machine:
<Steps>
### Fork Documenso
Fork the [Documenso repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
### Clone Repository
After forking the repository, clone it to your local device by using the following command:
```bash
git clone https://github.com/<your-username>/documenso
```
### Install Dependencies
Run `npm i` in the root directory to install the dependencies required for the project.
### Set Up Environment Variables
Set up the following environment variables in the `.env` file:
```bash
NEXTAUTH_URL
NEXTAUTH_SECRET
NEXT_PUBLIC_WEBAPP_URL
NEXT_PUBLIC_MARKETING_URL
NEXT_PRIVATE_DATABASE_URL
NEXT_PRIVATE_DIRECT_DATABASE_URL
NEXT_PRIVATE_SMTP_FROM_NAME
NEXT_PRIVATE_SMTP_FROM_ADDRESS
```
Alternatively, you can run `cp .env.example .env` to get started with our handpicked defaults.
### Create Database Schema
Create the database schema by running the following command:
```bash
npm run prisma:migrate-dev
```
### Optional: Seed the Database
Seed the database with test data by running the following command:
```bash
npm run prisma:seed -w @documenso/prisma
```
### Start the Application
Run `npm run dev` in the root directory to start the application.
### Access the Application
Access the Documenso application by visiting `http://localhost:3000` in your web browser.
</Steps>
<Callout type="info">
Optional: Create your signing certificate. To generate your own using these steps and a Linux
Terminal or Windows Subsystem for Linux (WSL), see **[Create your signing
certificate](./SIGNING.md)**.
</Callout>

View File

@ -1,66 +0,0 @@
---
title: Developer Quickstart
description: Quickly set up Documenso on your machine for local development with Docker and Docker Compose.
---
import { Callout, Steps } from 'nextra/components';
# Developer Quickstart
<Callout type="info">
**Note**: This guide assumes that you have both [docker](https://docs.docker.com/get-docker/) and
[docker-compose](https://docs.docker.com/compose/) installed on your machine.
</Callout>
Want to get up and running quickly? Follow these steps:
<Steps>
### Fork Documenso
Fork the [Documenso repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
### Clone Repository
After forking the repository, clone it to your local device by using the following command:
```bash
git clone https://github.com/<your-username>/documenso
```
### Set Up Environment Variables
Set up your environment variables in the `.env` file using the `.env.example` file as a reference.
Alternatively, you can run `cp .env.example .env` to get started with our handpicked defaults.
### Start Database and Mail Server
Run `npm run dx` in the root directory.
This will spin up a Postgres database and inbucket mailserver in a docker container.
### Start the Application
Run `npm run dev` in the root directory to start the application.
### Optional: Fasten the Process
Want it even faster? Just use:
```sh
npm run d
```
</Steps>
### Access Points for the Project
You can access the following services:
- Main application - http://localhost:3000
- Incoming Mail Access - http://localhost:9000
- Database Connection Details:
- Port: 54320
- Connection: Use your favourite database client to connect to the database.
- S3 Storage Dashboard - http://localhost:9001

View File

@ -1,6 +0,0 @@
{
"intro": "Intro",
"authentication": "Authentication",
"versioning": "Versioning",
"api-reference": "API Reference"
}

View File

@ -1,6 +0,0 @@
---
title: 'API Reference'
description: 'Reference documentation for the Documenso public API.'
---
# API Reference

View File

@ -1,67 +0,0 @@
---
title: 'API Authentication'
description: 'Learn how to create a Documenso API key and authenticate your API requests.'
---
# API Authentication
Documenso uses API keys for authentication. An API key is a unique token that is generated for each client. The client must provide the key whenever it makes an API call. This way, Documenso can identify the clients making the requests and authorize their access to the API.
## Creating an API Key
To create an API key, navigate to the user settings page. Click on your avatar in the top right corner of the dashboard and select "**[User settings](https://app.documenso.com/settings)**" from the dropdown menu.
![A screenshot of the Documenso's dashboard that shows the dropdown menu when you click on your user avatar](/public-api-images/documenso-user-dropdown-menu.webp)
Once you're on the user settings page, navigate to the "**[API Tokens](https://app.documenso.com/settings/tokens)**" tab. The "API Token" page lists your existing keys and enables you to create new ones.
![A screenshot of the Documenso's user settings page that shows the API Tokens page](/public-api-images/api-tokens-page-documenso.webp)
To create a new API key, you must:
- Choose a name (e.g. "zapier-key")
- we recommend using a descriptive name that helps you quickly identify the key and its purpose
- Choose an expiration date
- you can set the key to never expire or choose when to become invalid: 7 days, 1 month, 3 months, 6 months, or 1 year
After providing the required information, click the "Create token" button to generate the API key.
![A screenshot of the newly created API token in the Documenso dashboard](/public-api-images/documenso-api-key-blurred.webp)
Once you've created the token, Documenso will display the key on the screen. Make sure to copy the key and store it securely. You won't be able to see the key again once you refresh/leave the page.
## Using the API Key
To authenticate your API requests, you must include the API key in the `Authorization` request header. The format is `Authorization: api_xxxxxxxxxxxxxxxx`.
Here's a sample API request using cURL:
```bash
curl --location 'https://app.documenso.com/api/v1/documents?page=1&perPage=1' \
--header 'Authorization: api_xxxxxxxxxxxxxxxx'
```
Here's a sample response from the API based on the above cURL request:
```json
{
"documents": [
{
"id": 11,
"userId": 2,
"teamId": null,
"title": "documenso",
"status": "PENDING",
"documentDataId": "ab2ecm1npk11rt5sp398waf7h",
"createdAt": "2024-04-25T11:05:18.420Z",
"updatedAt": "2024-04-25T11:05:36.328Z",
"completedAt": null
}
],
"totalPages": 1
}
```
![A screenshot of a cURL request to the Documenso public API with the API key in the Authorization header](/public-api-images/documenso-api-authorization.webp)
The API key has access to your account and all its resources. Please keep it secure and do not share it with others. If you suspect that your key has been compromised, you can revoke it from the API Tokens page in your user settings.

View File

@ -1,22 +0,0 @@
---
title: 'Public API'
description: 'Learn how to interact with your documents programmatically using the Documenso public API.'
---
# Public API
Documenso provides a public REST API that enables you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as:
- retrieving, uploading, deleting, and sending documents for signing
- creating, updating, and deleting recipients
- creating, updating, and deleting document fields
The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API.
## Swagger Documentation
There is also the [Swagger documentation](https://app.documenso.com/api/v1/openapi) available, which provides information about the API endpoints, request parameters, response formats, and authentication methods.
## Availability
The API is available to individual users and teams.

View File

@ -1,18 +0,0 @@
---
title: 'API Versioning'
description: 'Versioning information for the Documenso public API.'
---
import { Callout } from 'nextra/components';
# API Versioning
Documenso uses API versioning to manage changes to the public API. This allows us to introduce new features, fix bugs, and make other changes without breaking existing integrations.
<Callout type="info">The current version of the API is `v1`.</Callout>
The API version is specified in the URL. For example, the base URL for the `v1` API is `https://app.documenso.com/api/v1`.
We may make changes to the API without incrementing the version number. We will always try to avoid breaking changes, but in some cases, it may be necessary to make changes that are not backward compatible. In these cases, we will increment the version number and provide information about the changes in the release notes.
Also, we may deprecate certain features or endpoints in the API. When we deprecate a feature or endpoint, we will provide information about the deprecation in the release notes and give a timeline for when the feature or endpoint will be removed.

View File

@ -1,8 +0,0 @@
{
"intro": {
"title": "Get started",
"theme": {
"sidebar": false
}
}
}

View File

@ -1,350 +0,0 @@
---
title: 'Webhooks'
description: 'Learn how to use webhooks to receive real-time notifications about your documents.'
---
# Webhooks
Webhooks are HTTP callbacks that are triggered by specific events. When the user subscribes to a specific event and that event occurs, the webhook makes an HTTP request to the URL provided by the user. The request can be a simple notification or can carry a payload with more information about the event.
Some of the common use cases for webhooks include:
1. **Real-time Data Syncing**: Webhooks provide a way to keep data in sync across different platforms. For example, you can keep your system up-to-date with your Documenso documents by subscribing to events like document creation or signing.
2. **Automating Workflows**: They can trigger automated workflows that start when an event occurs. For example, the webhook could trigger an email when a document is signed.
3. **Integrating Third-Party Services**: Webhooks can be used to integrate Documenso with third-party services. For example, you could use a webhook to send data to a CRM system when a document is signed.
Documenso supports Webhooks and allows you to subscribe to the following events:
- `document.created`
- `document.sent`
- `document.opened`
- `document.signed`
- `document.completed`
## Create a webhook subscription
You can create a webhook subscription from the user settings page. Click on your avatar in the top right corner of the dashboard and select "**[User settings](https://app.documenso.com/settings)**" from the dropdown menu.
![A screenshot of the Documenso's dashboard that shows the dropdown menu when you click on your user avatar](/webhook-images/dashboard-user-dropdown-menu.webp)
Then, navigate to the "**[Webhooks](https://app.documenso.com/settings/webhooks)**" tab, where you can see a list of your existing webhooks and create new ones.
![A screenshot of the Documenso's user settings page that shows the Webhooks tab and the Create Webhook button](/webhook-images/webhooks-settings-page.webp)
Clicking on the "**Create Webhook**" button opens a modal to create a new webhook subscription.
To create a new webhook subscription, you need to provide the following information:
- Enter the webhook URL that will receive the event payload.
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`.
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Signature` header of the request.
![A screenshot of the Create Webhook modal that shows the URL input field and the event checkboxes](/webhook-images/webhooks-page-create-webhook-modal.webp)
After you have filled in the required information, click on the "**Create Webhook**" button to save your subscription.
The screenshot below illustrates a newly created webhook subscription.
![A screenshot of the Documenso's user settings page that shows the newly created webhook subscription](/webhook-images/webhooks-page.webp)
You can edit or delete your webhook subscriptions by clicking the "**Edit**" or "**Delete**" buttons next to the webhook.
## Webhook fields
The payload sent to the webhook URL contains the following fields:
| Field | Type | Description |
| -------------------------------------------- | --------- | ---------------------------------------------------- |
| `event` | string | The type of event that triggered the webhook. |
| `payload.id` | number | The id of the document. |
| `payload.userId` | number | The id of the user who owns the document. |
| `payload.authOptions` | json? | Authentication options for the document. |
| `payload.formValues` | json? | Form values for the document. |
| `payload.title` | string | The name of the document. |
| `payload.status` | string | The current status of the document. |
| `payload.documentDataId` | string | The identifier for the document data. |
| `payload.createdAt` | datetime | The creation date and time of the document. |
| `payload.updatedAt` | datetime | The last update date and time of the document. |
| `payload.completedAt` | datetime? | The completion date and time of the document. |
| `payload.deletedAt` | datetime? | The deletion date and time of the document. |
| `payload.teamId` | number? | The id of the team. |
| `payload.documentData.id` | string | The id of the document data. |
| `payload.documentData.type` | string | The type of the document data. |
| `payload.documentData.data` | string | The data of the document. |
| `payload.documentData.initialData` | string | The initial data of the document. |
| `payload.Recipient[].id` | number | The id of the recipient. |
| `payload.Recipient[].documentId` | number? | The id the document associated with the recipient. |
| `payload.Recipient[].templateId` | number? | The template identifier for the recipient. |
| `payload.Recipient[].email` | string | The email address of the recipient. |
| `payload.Recipient[].name` | string | The name of the recipient. |
| `payload.Recipient[].token` | string | The token associated with the recipient. |
| `payload.Recipient[].expired` | datetime? | The expiration status of the recipient. |
| `payload.Recipient[].signedAt` | datetime? | The date and time the recipient signed the document. |
| `payload.Recipient[].authOptions.accessAuth` | json? | Access authentication options. |
| `payload.Recipient[].authOptions.actionAuth` | json? | Action authentication options. |
| `payload.Recipient[].role` | string | The role of the recipient. |
| `payload.Recipient[].readStatus` | string | The read status of the document by the recipient. |
| `payload.Recipient[].signingStatus` | string | The signing status of the recipient. |
| `payload.Recipient[].sendStatus` | string | The send status of the document to the recipient. |
| `createdAt` | datetime | The creation date and time of the webhook event. |
| `webhookEndpoint` | string | The endpoint URL where the webhook is sent. |
## Webhook event payload example
When an event that you have subscribed to occurs, Documenso will send a POST request to the specified webhook URL with a payload containing information about the event.
## Example payloads
Below are examples of the payloads that are sent for each of the supported events. The payloads are sent as JSON data in the body of the POST request.
Example payload for the `document.created` event:
```json
{
"event": "DOCUMENT_CREATED",
"payload": {
"id": 10,
"userId": 1,
"authOptions": null,
"formValues": null,
"title": "documenso.pdf",
"status": "DRAFT",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:44:43.341Z",
"completedAt": null,
"deletedAt": null,
"teamId": null
},
"createdAt": "2024-04-22T11:44:44.779Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.sent` event:
```json
{
"event": "DOCUMENT_SENT",
"payload": {
"id": 10,
"userId": 1,
"authOptions": null,
"formValues": null,
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer2@documenso.com",
"name": "Signer 2",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"expired": null,
"signedAt": null,
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"role": "VIEWER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
},
{
"id": 53,
"documentId": 10,
"templateId": null,
"email": "signer1@documenso.com",
"name": "Signer 1",
"token": "HkrptwS42ZBXdRKj1TyUo",
"expired": null,
"signedAt": null,
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"role": "SIGNER",
"readStatus": "NOT_OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:48:07.945Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.opened` event:
```json
{
"event": "DOCUMENT_OPENED",
"payload": {
"id": 10,
"userId": 1,
"authOptions": null,
"formValues": null,
"title": "documenso.pdf",
"status": "PENDING",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:48:07.569Z",
"completedAt": null,
"deletedAt": null,
"teamId": null,
"Recipient": [
{
"id": 52,
"documentId": 10,
"templateId": null,
"email": "signer2@documenso.com",
"name": "Signer 2",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"expired": null,
"signedAt": null,
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"role": "VIEWER",
"readStatus": "OPENED",
"signingStatus": "NOT_SIGNED",
"sendStatus": "SENT"
}
],
"documentData": {
"id": "hs8qz1ktr9204jn7mg6c5dxy0",
"type": "S3_PATH",
"data": "9753/xzqrshtlpokm/documenso.pdf",
"initialData": "9753/xzqrshtlpokm/documenso.pdf"
}
},
"createdAt": "2024-04-22T11:50:26.174Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.signed` event:
```json
{
"event": "DOCUMENT_SIGNED",
"payload": {
"id": 10,
"userId": 1,
"authOptions": null,
"formValues": null,
"title": "documenso.pdf",
"status": "COMPLETED",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:52:05.708Z",
"completedAt": "2024-04-22T11:52:05.707Z",
"deletedAt": null,
"teamId": null,
"Recipient": [
{
"id": 51,
"documentId": 10,
"templateId": null,
"email": "signer1@documenso.com",
"name": "Signer 1",
"token": "HkrptwS42ZBXdRKj1TyUo",
"expired": null,
"signedAt": "2024-04-22T11:52:05.688Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:52:18.577Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
Example payload for the `document.completed` event:
```json
{
"event": "DOCUMENT_COMPLETED",
"payload": {
"id": 10,
"userId": 1,
"authOptions": null,
"formValues": null,
"title": "documenso.pdf",
"status": "COMPLETED",
"documentDataId": "hs8qz1ktr9204jn7mg6c5dxy0",
"createdAt": "2024-04-22T11:44:43.341Z",
"updatedAt": "2024-04-22T11:52:05.708Z",
"completedAt": "2024-04-22T11:52:05.707Z",
"deletedAt": null,
"teamId": null,
"documentData": {
"id": "hs8qz1ktr9204jn7mg6c5dxy0",
"type": "S3_PATH",
"data": "bk9p1h7x0s3m/documenso-signed.pdf",
"initialData": "9753/xzqrshtlpokm/documenso.pdf"
},
"Recipient": [
{
"id": 50,
"documentId": 10,
"templateId": null,
"email": "signer2@documenso.com",
"name": "Signer 2",
"token": "vbT8hi3jKQmrFP_LN1WcS",
"expired": null,
"signedAt": "2024-04-22T11:51:10.055Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"role": "VIEWER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
},
{
"id": 51,
"documentId": 10,
"templateId": null,
"email": "signer1@documenso.com",
"name": "Signer 1",
"token": "HkrptwS42ZBXdRKj1TyUo",
"expired": null,
"signedAt": "2024-04-22T11:52:05.688Z",
"authOptions": {
"accessAuth": null,
"actionAuth": null
},
"role": "SIGNER",
"readStatus": "OPENED",
"signingStatus": "SIGNED",
"sendStatus": "SENT"
}
]
},
"createdAt": "2024-04-22T11:52:18.277Z",
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
}
```
## Availability
Webhooks are available to individual users and teams.

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,20 +0,0 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
},
},
plugins: [],
};
export default config;

View File

@ -1,49 +0,0 @@
import { useConfig } from 'nextra-theme-docs';
import type { DocsThemeConfig } from 'nextra-theme-docs';
const themeConfig: DocsThemeConfig = {
logo: <span>Documenso Docs</span>,
head: function useHead() {
const config = useConfig<{ title?: string; description?: string }>();
const title = `${config.frontMatter.title} | Documenso Docs` || 'Documenso Docs';
const description = config.frontMatter.description || 'The official Documenso documentation';
return (
<>
<meta httpEquiv="Content-Language" content="en" />
<meta name="title" content={title} />
<meta name="og:title" content={title} />
<meta name="description" content={description} />
<meta name="og:description" content={description} />
</>
);
},
project: {
link: 'https://documen.so/github',
},
chat: {
link: 'https://documen.so/discord',
},
docsRepositoryBase: 'https://github.com/documenso/documenso/tree/main/apps/documentation',
footer: {
text: (
<span>
{new Date().getFullYear()} ©{' '}
<a href="https://documen.so" target="_blank">
Documenso
</a>
.
</span>
),
},
primaryHue: 100,
primarySaturation: 48.47,
useNextSeoProps() {
return {
titleTemplate: '%s | Documenso Docs',
};
},
};
export default themeConfig;

View File

@ -1,27 +0,0 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
},
"forceConsistentCasingInFileNames": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
}
try {
const putFileData = await putFile(uploadedFile.file);
const putFileData = await putPdfFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({
documentData: {

View File

@ -10,8 +10,9 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
try {
setIsLoading(true);
const { type, data } = await putFile(file);
const { type, data } = await putPdfFile(file);
const { id: documentDataId } = await createDocumentData({
type,
@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
});
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (error) {
console.error(error);
} catch (err) {
const error = AppError.parseError(err);
if (error instanceof TRPCClientError) {
console.error(err);
if (error.code === 'INVALID_DOCUMENT_FILE') {
toast({
title: 'Invalid file',
description: 'You cannot upload encrypted PDFs',
variant: 'destructive',
});
} else if (err instanceof TRPCClientError) {
toast({
title: 'Error',
description: error.message,
description: err.message,
variant: 'destructive',
});
} else {

View File

@ -12,7 +12,7 @@ import * as z from 'zod';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -98,7 +98,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const file: File = uploadedFile.file;
try {
const { type, data } = await putFile(file);
const { type, data } = await putPdfFile(file);
const { id: templateDocumentDataId } = await createDocumentData({
type,

View File

@ -1,14 +1,16 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { InfoIcon, Plus } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod';
import { TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
@ -19,24 +21,59 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
email: z.string().email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
});
const ZAddRecipientsForNewDocumentSchema = z
.object({
sendDocument: z.boolean(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
}),
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@ -56,33 +93,31 @@ export function UseTemplateDialog({
const team = useOptionalCurrentTeam();
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddRecipientsForNewDocumentSchema>({
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
recipients:
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
}))
: [
{
name: '',
email: '',
role: RecipientRole.SIGNER,
},
],
sendDocument: false,
recipients: recipients.map((recipient) => {
const isRecipientPlaceholder = recipient.email.match(TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX);
if (isRecipientPlaceholder) {
return {
id: recipient.id,
name: '',
email: '',
};
}
return {
id: recipient.id,
name: recipient.name,
email: recipient.email,
};
}),
},
});
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
@ -91,6 +126,7 @@ export function UseTemplateDialog({
templateId,
teamId: team?.id,
recipients: data.recipients,
sendDocument: data.sendDocument,
});
toast({
@ -101,18 +137,24 @@ export function UseTemplateDialog({
router.push(`${documentRootPath}/${id}`);
} catch (err) {
toast({
const error = AppError.parseError(err);
const toastPayload: Toast = {
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
};
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = 'The document was created but could not be sent to recipients.';
}
toast(toastPayload);
}
};
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
const { fields: formRecipients } = useFieldArray({
control,
control: form.control,
name: 'recipients',
});
@ -126,121 +168,110 @@ export function UseTemplateDialog({
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Document Recipients</DialogTitle>
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
<DialogTitle>Create document from template</DialogTitle>
<DialogDescription>
{recipients.length === 0
? 'A draft document will be created'
: 'Add the recipients to create the document with'}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-4">
{formRecipients.map((recipient, index) => (
<div
key={recipient.id}
data-native-id={recipient.id}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller
control={control}
name={`recipients.${index}.email`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
<FormField
control={form.control}
name={`recipients.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email</FormLabel>}
<FormControl>
<Input {...field} placeholder={recipients[index].email} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
<FormField
control={form.control}
name={`recipients.${index}.name`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel>Name</FormLabel>}
<Controller
control={control}
name={`recipients.${index}.name`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
<FormControl>
<Input {...field} placeholder={recipients[index].name} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</div>
))}
</div>
<div className="w-[60px]">
<Controller
control={control}
name={`recipients.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
{recipients.length > 0 && (
<div className="mt-4 flex flex-row items-center">
<FormField
control={form.control}
name="sendDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="sendDocument"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="sendDocument"
>
Send document
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
The document will be immediately sent to recipients if this is
checked.
</p>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<p>Otherwise, the document will be created as a draft.</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
</div>
)}
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
</div>
</div>
))}
</div>
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button
type="button"
loading={isCreatingDocumentFromTemplate}
disabled={isCreatingDocumentFromTemplate}
onClick={onCreateDocumentFromTemplate}
>
Create Document
</Button>
</DialogFooter>
<Button type="submit" loading={form.formState.isSubmitting}>
{form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
export default function SignatureDisclosure() {
return (
<div>
<article className="prose">
<article className="prose dark:prose-invert">
<h1>Electronic Signature Disclosure</h1>
<h2>Welcome</h2>

View File

@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
@ -18,15 +20,29 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
error: '/signin',
},
events: {
signIn: async ({ user }) => {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
});
signIn: async ({ user: { id: userId } }) => {
const [user] = await Promise.all([
await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
await prisma.userSecurityAuditLog.create({
data: {
userId,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
}),
]);
// Create the Stripe customer and attach it to the user if it doesn't exist.
if (user.customerId === null && IS_BILLING_ENABLED()) {
await getStripeCustomerByUser(user).catch((err) => {
console.error(err);
});
}
},
signOut: async ({ token }) => {
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;

View File

@ -3,7 +3,7 @@ import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
export const config = {
maxDuration: 60,
maxDuration: 120,
api: {
bodyParser: {
sizeLimit: '50mb',

2742
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,8 +3,8 @@
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing --filter=@documenso/documentation",
"start": "turbo run start --filter=@documenso/web --filter=@documenso/marketing --filter=@documenso/documentation",
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
"start": "turbo run start --filter=@documenso/web --filter=@documenso/marketing",
"lint": "turbo run lint",
"lint:fix": "turbo run lint:fix",
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",

View File

@ -22,7 +22,7 @@ import { updateRecipient } from '@documenso/lib/server-only/recipient/update-rec
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import {
getPresignGetUrl,
getPresignPostUrl,
@ -229,6 +229,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
requestMetadata: extractNextApiRequestMetadata(args.req),
});
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const recipients = await setRecipientsForDocument({
userId: user.id,
teamId: team?.id,
@ -296,7 +303,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
formValues: body.formValues,
});
const newDocumentData = await putFile({
const newDocumentData = await putPdfFile({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@ -324,10 +331,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
subject: body.meta.subject,
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
}

View File

@ -100,13 +100,21 @@ export type TCreateDocumentMutationResponseSchema = z.infer<
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
title: z.string().min(1),
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
}),
),
recipients: z.union([
z.array(
z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email().min(1),
}),
),
z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
}),
),
]),
meta: z
.object({
subject: z.string(),

View File

@ -189,7 +189,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents');
@ -200,7 +207,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use team template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`);

View File

@ -5,7 +5,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { alphaid, nanoid } from '@documenso/lib/universal/id';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
@ -74,7 +74,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
).then(async (res) => res.arrayBuffer());
const { id: documentDataId } = await putFile({
const { id: documentDataId } = await putPdfFile({
name: 'Documenso Supporter Pledge.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(documentBuffer),

View File

@ -21,6 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account.
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_allow_encrypted_documents: false,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.

View File

@ -0,0 +1 @@
export const TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;

View File

@ -137,7 +137,7 @@ export const completeDocumentWithToken = async ({
await sendPendingEmail({ documentId, recipientId: recipient.id });
}
const documents = await prisma.document.updateMany({
const haveAllRecipientsSigned = await prisma.document.findFirst({
where: {
id: document.id,
Recipient: {
@ -146,13 +146,9 @@ export const completeDocumentWithToken = async ({
},
},
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
if (documents.count > 0) {
if (haveAllRecipientsSigned) {
await sealDocument({ documentId: document.id, requestMetadata });
}

View File

@ -75,18 +75,20 @@ export const deleteDocument = async ({
}
// Continue to hide the document from the user if they are a recipient.
// Dirty way of doing this but it's faster than refetching the document.
if (userRecipient?.documentDeletedAt === null) {
await prisma.recipient.update({
where: {
documentId_email: {
documentId: document.id,
email: user.email,
await prisma.recipient
.update({
where: {
id: userRecipient.id,
},
},
data: {
documentDeletedAt: new Date().toISOString(),
},
});
data: {
documentDeletedAt: new Date().toISOString(),
},
})
.catch(() => {
// Do nothing.
});
}
// Return partial document for API v1 response.

View File

@ -135,7 +135,7 @@ export const resendDocument = async ({
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),

View File

@ -14,7 +14,7 @@ import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
@ -40,6 +40,11 @@ export const sealDocument = async ({
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
},
include: {
documentData: true,
@ -53,10 +58,6 @@ export const sealDocument = async ({
throw new Error(`Document ${document.id} has no document data`);
}
if (document.status !== DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has not been completed`);
}
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
@ -92,9 +93,9 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
const certificate = await getCertificatePdf({ documentId }).then(async (doc) =>
PDFDocument.load(doc),
);
const certificate = await getCertificatePdf({ documentId })
.then(async (doc) => PDFDocument.load(doc))
.catch(() => null);
const doc = await PDFDocument.load(pdfData);
@ -103,11 +104,13 @@ export const sealDocument = async ({
doc.getForm().flatten();
flattenAnnotations(doc);
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
if (certificate) {
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
certificatePages.forEach((page) => {
doc.addPage(page);
});
certificatePages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
await insertFieldInPDF(doc, field);
@ -119,7 +122,7 @@ export const sealDocument = async ({
const { name, ext } = path.parse(document.title);
const { data: newData } = await putFile({
const { data: newData } = await putPdfFile({
name: `${name}_signed${ext}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
@ -138,6 +141,16 @@ export const sealDocument = async ({
}
await prisma.$transaction(async (tx) => {
await tx.document.update({
where: {
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentData.update({
where: {
id: documentData.id,

View File

@ -4,8 +4,11 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
@ -18,7 +21,6 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -100,7 +102,7 @@ export const sendDocument = async ({
formValues: document.formValues as Record<string, string | number | boolean>,
});
const newDocumentData = await putFile({
const newDocumentData = await putPdfFile({
name: document.title,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@ -211,6 +213,31 @@ export const sendDocument = async ({
}),
);
const allRecipientsHaveNoActionToTake = document.Recipient.every(
(recipient) => recipient.role === RecipientRole.CC,
);
if (allRecipientsHaveNoActionToTake) {
const updatedDocument = await updateDocument({
documentId,
userId,
teamId,
data: { status: DocumentStatus.COMPLETED },
});
await sealDocument({ documentId: updatedDocument.id, requestMetadata });
// Keep the return type the same for the `sendDocument` method
return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
Recipient: true,
},
});
}
const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({

View File

@ -35,6 +35,7 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
const result = await page.pdf({

View File

@ -1,16 +1,35 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type RecipientWithId = {
id: number;
name?: string;
email: string;
};
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role'> & {
templateRecipientId: number;
fields: Field[];
};
export type CreateDocumentFromTemplateOptions = {
templateId: number;
userId: number;
teamId?: number;
recipients?: {
name?: string;
email: string;
role?: RecipientRole;
}[];
recipients:
| RecipientWithId[]
| {
name?: string;
email: string;
}[];
requestMetadata?: RequestMetadata;
};
export const createDocumentFromTemplate = async ({
@ -18,7 +37,14 @@ export const createDocumentFromTemplate = async ({
userId,
teamId,
recipients,
requestMetadata,
}: CreateDocumentFromTemplateOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const template = await prisma.template.findUnique({
where: {
id: templateId,
@ -39,8 +65,11 @@ export const createDocumentFromTemplate = async ({
}),
},
include: {
Recipient: true,
Field: true,
Recipient: {
include: {
Field: true,
},
},
templateDocumentData: true,
},
});
@ -49,6 +78,43 @@ export const createDocumentFromTemplate = async ({
throw new Error('Template not found.');
}
if (recipients.length !== template.Recipient.length) {
throw new Error('Invalid number of recipients.');
}
let finalRecipients: FinalRecipient[] = [];
if (recipients.length > 0 && Object.prototype.hasOwnProperty.call(recipients[0], 'id')) {
finalRecipients = template.Recipient.map((templateRecipient) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const foundRecipient = (recipients as RecipientWithId[]).find(
(recipient) => recipient.id === templateRecipient.id,
);
if (!foundRecipient) {
throw new Error('Recipient not found.');
}
return {
templateRecipientId: templateRecipient.id,
fields: templateRecipient.Field,
name: foundRecipient.name ?? '',
email: foundRecipient.email,
role: templateRecipient.role,
};
});
} else {
// Backwards compatible logic for /v1/ API where we use the index to associate
// the provided recipient with the template recipient.
finalRecipients = recipients.map((recipient, index) => ({
templateRecipientId: template.Recipient[index].id,
fields: template.Recipient[index].Field,
name: recipient.name ?? '',
email: recipient.email,
role: template.Recipient[index].role,
}));
}
const documentData = await prisma.documentData.create({
data: {
type: template.templateDocumentData.type,
@ -57,85 +123,82 @@ export const createDocumentFromTemplate = async ({
},
});
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
include: {
Recipient: {
orderBy: {
id: 'asc',
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {
createMany: {
data: finalRecipients.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
},
documentData: true,
},
});
include: {
Recipient: {
orderBy: {
id: 'asc',
},
},
documentData: true,
},
});
await prisma.field.createMany({
data: template.Field.map((field) => {
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.Recipient.find((recipient) => recipient.email === email);
if (!documentRecipient) {
if (!recipient) {
throw new Error('Recipient not found.');
}
return {
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: field.customText,
inserted: field.inserted,
fieldsToCreate = fieldsToCreate.concat(
fields.map((field) => ({
documentId: document.id,
recipientId: recipient.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
})),
);
});
await tx.field.createMany({
data: fieldsToCreate,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
recipientId: documentRecipient.id,
};
}),
});
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
user,
requestMetadata,
data: {
title: document.title,
},
}),
);
}
});
return document;
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: document,
userId,
teamId,
});
return document;
});
};

View File

@ -1,9 +1,12 @@
import { base64 } from '@scure/base';
import { env } from 'next-runtime-env';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { DocumentDataType } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
type File = {
@ -12,14 +15,38 @@ type File = {
arrayBuffer: () => Promise<ArrayBuffer>;
};
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
export const putPdfFile = async (file: File) => {
const isEncryptedDocumentsAllowed = await getFlag('app_allow_encrypted_documents').catch(
() => false,
);
// This will prevent uploading encrypted PDFs or anything that can't be opened.
if (!isEncryptedDocumentsAllowed) {
await PDFDocument.load(await file.arrayBuffer()).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
}
const { type, data } = await putFile(file);
return await createDocumentData({ type, data });
};
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file));
return await createDocumentData({ type, data });
};
const putFileInDatabase = async (file: File) => {

View File

@ -10,7 +10,7 @@ import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/cons
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
@ -86,7 +86,7 @@ export const singleplayerRouter = router({
},
});
const { id: documentDataId } = await putFile({
const { id: documentDataId } = await putPdfFile({
name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),

View File

@ -1,10 +1,14 @@
import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError } from '@documenso/lib/errors/app-error';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Document } from '@documenso/prisma/client';
import { authenticatedProcedure, router } from '../trpc';
import {
@ -49,19 +53,34 @@ export const templateRouter = router({
throw new Error('You have reached your document limit.');
}
return await createDocumentFromTemplate({
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
let document: Document = await createDocumentFromTemplate({
templateId,
teamId,
userId: ctx.user.id,
recipients: input.recipients,
requestMetadata,
});
if (input.sendDocument) {
document = await sendDocument({
documentId: document.id,
userId: ctx.user.id,
teamId,
requestMetadata,
}).catch((err) => {
console.error(err);
throw new AppError('DOCUMENT_SEND_FAILED');
});
}
return document;
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this document. Please try again later.',
});
throw AppError.parseErrorToTRPCError(err);
}
}),

View File

@ -1,7 +1,5 @@
import { z } from 'zod';
import { RecipientRole } from '@documenso/prisma/client';
export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1).trim(),
teamId: z.number().optional(),
@ -11,15 +9,14 @@ export const ZCreateTemplateMutationSchema = z.object({
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().optional(),
recipients: z
.array(
z.object({
email: z.string().email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string().optional(),
}),
),
sendDocument: z.boolean().optional(),
});
export const ZDuplicateTemplateMutationSchema = z.object({

View File

@ -103,6 +103,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
appendSigner({
formId: nanoid(12),
name: `Recipient ${placeholderRecipientCount}`,
// Update TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX if this is ever changed.
email: `recipient.${placeholderRecipientCount}@documenso.com`,
role: RecipientRole.SIGNER,
});
@ -282,6 +283,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={
isSubmitting || getValues('signers').some((signer) => signer.email === user?.email)
}