Compare commits
65 Commits
chore/blog
...
blog/upcom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31829f99cf | ||
|
|
e2237ee67b | ||
|
|
89fe8842f2 | ||
|
|
db6ef10829 | ||
|
|
9742898cb6 | ||
|
|
0bf1310ae0 | ||
|
|
b6ed07b46b | ||
|
|
e6e8de62c8 | ||
|
|
71c7a6ee8c | ||
|
|
ecc8e59c8c | ||
|
|
d0f027c4ea | ||
|
|
2c667f785c | ||
|
|
1adf7e183e | ||
|
|
58580c7fac | ||
|
|
68020006b4 | ||
|
|
70cb65d266 | ||
|
|
cef8cad14c | ||
|
|
def8f45f8b | ||
|
|
ca325cc90b | ||
|
|
09c7f9dde8 | ||
|
|
0060b9da8c | ||
|
|
bad88a2a83 | ||
|
|
96a79b8879 | ||
|
|
60ef9df721 | ||
|
|
2d8ca8fea0 | ||
|
|
b411db40da | ||
|
|
1be0b9e01f | ||
|
|
d41ca8e0e6 | ||
|
|
d4659eee07 | ||
|
|
b93e3c0b52 | ||
|
|
079963cde8 | ||
|
|
45f447c796 | ||
|
|
2327b15e0d | ||
|
|
166cbc150f | ||
|
|
f561ef3cda | ||
|
|
29bd4cb9c3 | ||
|
|
1237944b71 | ||
|
|
b331e3c780 | ||
|
|
7f641e3e73 | ||
|
|
b84f0548d2 | ||
|
|
0f92534f00 | ||
|
|
7a489f241a | ||
|
|
f88e529111 | ||
|
|
47d55a5eab | ||
|
|
9dcab76cd5 | ||
|
|
dfebdfccda | ||
|
|
c3d9cac43f | ||
|
|
74355244a4 | ||
|
|
8be52e2fa3 | ||
|
|
0d702e9189 | ||
|
|
425db8fc1f | ||
|
|
2356f58e7b | ||
|
|
6c12ed4afc | ||
|
|
d76ee7f33c | ||
|
|
f8534b2c3d | ||
|
|
9014f01276 | ||
|
|
71818c0f1f | ||
|
|
ed4cbe9fa6 | ||
|
|
a2ef9468ae | ||
|
|
40274021ba | ||
|
|
3cbc722b94 | ||
|
|
8844143ff5 | ||
|
|
7de7624477 | ||
|
|
7c6b5ac59d | ||
|
|
9c45ce61b8 |
@@ -1,20 +1,32 @@
|
||||
{
|
||||
"name": "Documenso",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"version": "latest",
|
||||
"enableNonRootDocker": "true",
|
||||
"moby": "true"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
},
|
||||
"name": "Documenso",
|
||||
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"version": "latest",
|
||||
"enableNonRootDocker": "true",
|
||||
"moby": "true"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
},
|
||||
"onCreateCommand": "./.devcontainer/on-create.sh",
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
54320,
|
||||
9000,
|
||||
2500,
|
||||
1100
|
||||
]
|
||||
"forwardPorts": [3000, 54320, 9000, 2500, 1100],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"aaron-bond.better-comments",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"mikestead.dotenv",
|
||||
"unifiedjs.vscode-mdx",
|
||||
"GitHub.copilot-chat",
|
||||
"GitHub.copilot-labs",
|
||||
"GitHub.copilot",
|
||||
"GitHub.vscode-pull-request-github",
|
||||
"Prisma.prisma",
|
||||
"VisualStudioExptTeam.vscodeintellicode",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
.env.example
18
.env.example
@@ -7,14 +7,28 @@ NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# [[APP]]
|
||||
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
||||
|
||||
# [[DATABASE]]
|
||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||
|
||||
# [[STORAGE]]
|
||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
||||
NEXT_PRIVATE_UPLOAD_ENDPOINT=
|
||||
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
||||
NEXT_PRIVATE_UPLOAD_REGION=
|
||||
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
||||
NEXT_PRIVATE_UPLOAD_BUCKET=
|
||||
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
|
||||
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
|
||||
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
|
||||
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
|
||||
|
||||
# [[SMTP]]
|
||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||
NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth"
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
# Statically hosted javascript files
|
||||
apps/*/public/*.js
|
||||
apps/*/public/*.cjs
|
||||
scripts/
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -22,12 +22,18 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Copy env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
3
.github/workflows/codeql-analysis.yml
vendored
3
.github/workflows/codeql-analysis.yml
vendored
@@ -33,6 +33,9 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Copy env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Build Documenso
|
||||
run: npm run build
|
||||
|
||||
|
||||
116
README.md
116
README.md
@@ -1,13 +1,11 @@
|
||||
<p align="center" style="margin-top: 120px">
|
||||
<a href="https://github.com/documenso/documenso.com">
|
||||
<a href="https://github.com/documenso/documenso">
|
||||
<img width="250px" src="https://github.com/documenso/documenso/assets/1309312/cd7823ec-4baa-40b9-be78-4acb3b1c73cb" alt="Documenso Logo">
|
||||
</a>
|
||||
|
||||
<h3 align="center">Open Source Signing Infrastructure</h3>
|
||||
|
||||
<p align="center">
|
||||
The DocuSign Open Source Alternative.
|
||||
<br />
|
||||
The Open Source DocuSign Alternative.
|
||||
<br>
|
||||
<a href="https://documenso.com"><strong>Learn more »</strong></a>
|
||||
<br />
|
||||
<br />
|
||||
@@ -28,6 +26,10 @@
|
||||
<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>
|
||||
</p>
|
||||
|
||||
> **🚧 We're currently working on a large scale refactor which can be found on the [feat/refresh](https://github.com/documenso/documenso/tree/feat/refresh) branch.**
|
||||
>
|
||||
> **[Read more on why 👀](https://documenso.com/blog/why-were-doing-a-rewrite)**
|
||||
|
||||
# Documenso 0.9 - Developer Preview
|
||||
|
||||
<div>
|
||||
@@ -63,18 +65,28 @@ Signing documents digitally is fast, easy and should be best practice for every
|
||||
|
||||
## Community and Next Steps 🎯
|
||||
|
||||
The current project goal is to <b>[release a production ready version](https://github.com/documenso/documenso/milestone/1)</b> for self-hosting as soon as possible. If you want to help making that happen you can:
|
||||
We're currently working on a redesign of the application including a revamp of the codebase so Documenso can be more intuitive to use and robust to develop upon.
|
||||
|
||||
- Check out the first source code release in this repository and test it
|
||||
- Tell us what you think in the current [Discussions](https://github.com/documenso/documenso/discussions)
|
||||
- Join the [Slack Channel](https://documen.so/slack) for any questions and getting to know to other community members
|
||||
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members
|
||||
- ⭐ the repository to help us raise awareness
|
||||
- Spread the word on Twitter, that Documenso is working towards a more open signing tool
|
||||
- Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release
|
||||
|
||||
## Contributing
|
||||
|
||||
- To contribute please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
|
||||
- To contribute, please see our [contribution guide](https://github.com/documenso/documenso/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## Contact us
|
||||
|
||||
Contact us if you are interested in our Enterprise plan for large organizations that need extra flexibility and control.
|
||||
|
||||
<a href="https://cal.com/timurercan/enterprise-customers?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
|
||||
|
||||
## Activity
|
||||
|
||||

|
||||
|
||||
# Tech
|
||||
|
||||
@@ -89,10 +101,6 @@ Documenso is built using awesome open source tech including:
|
||||
- [Node SignPDF (Digital Signature)](https://github.com/vbuch/node-signpdf)
|
||||
- [React-PDF for viewing PDFs](https://github.com/wojtekmaj/react-pdf)
|
||||
- [PDF-Lib for PDF manipulation](https://github.com/Hopding/pdf-lib)
|
||||
- [Zod for schema declaration and validation](https://zod.dev/)
|
||||
- [Lucide React for icons in React app](https://lucide.dev/)
|
||||
- [Framer Motion for motion library](https://www.framer.com/motion/)
|
||||
- [Radix UI for component library](https://www.radix-ui.com/)
|
||||
- Check out `/package.json` and `/apps/web/package.json` for more
|
||||
- Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned.
|
||||
|
||||
@@ -135,37 +143,47 @@ Your database will also be available on port `54320`. You can connect to it usin
|
||||
|
||||
## Developer Setup
|
||||
|
||||
### Manual Setup
|
||||
|
||||
Follow these steps to setup documenso on you local machine:
|
||||
|
||||
- [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
```sh
|
||||
git clone https://github.com/documenso/documenso
|
||||
```
|
||||
- Run <code>npm i</code> in root directory
|
||||
- Rename <code>.env.example</code> to <code>.env</code>
|
||||
- Run `npm i` in root directory
|
||||
- Rename `.env.example` to `.env`
|
||||
- Set DATABASE_URL value in .env file
|
||||
- You can use the provided test database url (may be wiped at any point)
|
||||
- Or setup a local postgres sql instance (recommended)
|
||||
- Create the database scheme by running <code>db-migrate:dev</code>
|
||||
- Create the database scheme by running `db-migrate:dev`
|
||||
- Setup your mail provider
|
||||
- Set <code>SENDGRID_API_KEY</code> value in .env file
|
||||
- Set `SENDGRID_API_KEY` value in .env file
|
||||
- You need a SendGrid account, which you can create [here](https://signup.sendgrid.com/).
|
||||
- Documenso uses [Nodemailer](https://nodemailer.com/about/) so you can easily use your own SMTP server by setting the <code>SMTP\_\* variables</code> in your .env
|
||||
- Run <code>npm run dev</code> root directory to start
|
||||
- Documenso uses [Nodemailer](https://nodemailer.com/about/) so you can easily use your own SMTP server by setting the `SMTP
|
||||
\_
|
||||
* variables` in your .env
|
||||
- Run `npm run dev` root directory to start
|
||||
- Register a new user at http://localhost:3000/signup
|
||||
|
||||
---
|
||||
|
||||
- Optional: Seed the database using <code>npm run db-seed</code> to create a test user and document
|
||||
- Optional: Upload and sign <code>apps/web/resources/example.pdf</code> manually to test your setup
|
||||
- Optional: Seed the database using `npm run db-seed` to create a test user and document
|
||||
- Optional: Upload and sign `apps/web/resources/example.pdf` manually to test your setup
|
||||
|
||||
- Optional: Create your own signing certificate
|
||||
- A demo certificate is provided in `/app/web/resources/certificate.p12`
|
||||
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL) see **[Create your own signing certificate](#creating-your-own-signing-certificate)**.
|
||||
|
||||
### Run in Gitpod
|
||||
|
||||
- Click below to launch a ready-to-use Gitpod workspace in your browser.
|
||||
|
||||
[](https://gitpod.io/#https://github.com/documenso/documenso)
|
||||
|
||||
## Updating
|
||||
|
||||
- If you pull the newest version from main, using <code>git pull</code>, it may be necessary to regenerate your database client
|
||||
- If you pull the newest version from main, using `git pull`, it may be necessary to regenerate your database client
|
||||
- You can do this by running the generate command in `/packages/prisma`:
|
||||
```sh
|
||||
npx prisma generate
|
||||
@@ -176,16 +194,22 @@ Follow these steps to setup documenso on you local machine:
|
||||
|
||||
For the digital signature of your documents you need a signing certificate in .p12 format (public and private key). You can buy one (not recommended for dev) or use the steps to create a self-signed one:
|
||||
|
||||
1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key:\
|
||||
<code>openssl genrsa -out private.key 2048</code>
|
||||
1. Generate a private key using the OpenSSL command. You can run the following command to generate a 2048-bit RSA key:
|
||||
|
||||
`openssl genrsa -out private.key 2048`
|
||||
|
||||
2. Generate a self-signed certificate using the private key. You can run the following command to generate a self-signed certificate:
|
||||
|
||||
`openssl req -new -x509 -key private.key -out certificate.crt -days 365`
|
||||
|
||||
2. Generate a self-signed certificate using the private key. You can run the following command to generate a self-signed certificate:\
|
||||
<code>openssl req -new -x509 -key private.key -out certificate.crt -days 365</code> \
|
||||
This will prompt you to enter some information, such as the Common Name (CN) for the certificate. Make sure you enter the correct information. The -days parameter sets the number of days for which the certificate is valid.
|
||||
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following command to do this: \
|
||||
<code>openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt</code>
|
||||
|
||||
3. Combine the private key and the self-signed certificate to create the p12 certificate. You can run the following command to do this:
|
||||
|
||||
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
|
||||
|
||||
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
|
||||
5. Place the certificate <code>/apps/web/resources/certificate.p12</code>
|
||||
5. Place the certificate `/apps/web/resources/certificate.p12`
|
||||
|
||||
# Docker
|
||||
|
||||
@@ -193,16 +217,42 @@ For the digital signature of your documents you need a signing certificate in .p
|
||||
|
||||
Want to create a production ready docker image? Follow these steps:
|
||||
|
||||
- Run `./docker/build.sh` in the root directory.
|
||||
- Publish the image to your docker registry of choice.
|
||||
- cd into `docker` directory
|
||||
- Make `build.sh` executable by running `chmod +x build.sh`
|
||||
- Run `./build.sh` to start building the docker image.
|
||||
- Publish the image to your docker registry of choice (or) If you prefer running the image from local, run the below command
|
||||
|
||||
# Deploying - Coming Soon™
|
||||
```
|
||||
docker run -d --restart=unless-stopped -p 3000:3000 -v documenso:/app/data --name documenso documenso:latest
|
||||
```
|
||||
|
||||
- Docker support
|
||||
- One-Click-Deploy on Render.com Deploy
|
||||
Command Breakdown:
|
||||
- `-d` - Let's you run the container in background
|
||||
- `-p` - Passes down which ports to use. First half is the host port, Second half is the app port. You can change the first half anything you want and reverse proxy to that port.
|
||||
- `-v` - Volume let's you persist the data
|
||||
- `--name` - Name of the container
|
||||
- `documenso:latest` - Image you have built
|
||||
|
||||
# Deployment
|
||||
|
||||
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
|
||||
|
||||
## Railway
|
||||
|
||||
[](https://railway.app/template/DjrRRX)
|
||||
|
||||
## Render
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## I'm not receiving any emails when using the developer quickstart
|
||||
|
||||
When using the developer quickstart an [Inbucket](https://inbucket.org/) server will be spun up in a docker container that will store all outgoing email locally for you to view.
|
||||
|
||||
The Web UI can be found at http://localhost:9000 while the SMTP port will be on localhost:2500.
|
||||
|
||||
## Support IPv6
|
||||
|
||||
In case you are deploying to a cluster that uses only IPv6. You can use a custom command to pass a parameter to the NextJS start command
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
title: 'Deploying Documenso with Vercel, Supabase and Resend'
|
||||
description: This is the first part of the new Building Documenso series, where I describe the challenges and design choices that we make while building the world’s most open signing platform.
|
||||
authorName: 'Ephraim Atta-Duncan'
|
||||
authorImage: '/blog/blog-author-duncan.jpeg'
|
||||
authorRole: 'Software Engineer Intern'
|
||||
date: 2023-09-08
|
||||
tags:
|
||||
- Open Source
|
||||
- Self Hosting
|
||||
- Tutorial
|
||||
---
|
||||
|
||||
In this article, we'll walk you through how to deploy and self-host Documenso using Vercel, Supabase, and Resend.
|
||||
|
||||
You'll learn:
|
||||
|
||||
- How to set up a Postgres database using Supabase,
|
||||
- How to install SMTP with Resend,
|
||||
- How to deploy your project with Vercel.
|
||||
|
||||
If you don't know what [Documenso](https://documenso.com/) is, it's an open-source alternative to DocuSign, with the mission to create an open signing infrastructure while embracing openness, cooperation, and transparency.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before we start, make sure you have a [GitHub](https://github.com/signup) account. You also need [Node.js](https://nodejs.org/en) and [npm](https://www.npmjs.com/) installed on your local machine (note: you also have the option to host it on a cloud environment using Gitpod for example; that would be another post). If you need accounts on Vercel, Supabase, and Resend, create them by visiting the [Vercel](https://vercel.com/), [Supabase](https://supabase.com/), and [Resend](https://resend.com/) websites.
|
||||
|
||||
Checklist:
|
||||
|
||||
- [ ] Have a GitHub account
|
||||
- [ ] Install Node.js
|
||||
- [ ] Install npm
|
||||
- [ ] Have a Vercel account
|
||||
- [ ] Have a Supabase account
|
||||
- [ ] Have a Resend account
|
||||
|
||||
## Step-by-Step guide to deploying Documenso with Vercel, Supabase, and Resend
|
||||
|
||||
To deploy Documenso, we'll take the following steps:
|
||||
|
||||
1. Fork the Documenso repository
|
||||
2. Clone the forked repository and install dependencies
|
||||
3. Create a new project on Supabase
|
||||
4. Copy the Supabase Postgres database connection URL
|
||||
5. Create a `.env` file
|
||||
6. Run the migration on the Supabase Postgres Database
|
||||
7. Get your SMTP Keys on Resend
|
||||
8. Create a new project on Vercel
|
||||
9. Add Environment Variables to your Vercel project
|
||||
|
||||
So, you're ready? Let’s dive in!
|
||||
|
||||
### Step 1: Fork the Documenso repository
|
||||
|
||||
Start by creating a fork of Documenso on GitHub. You can do this by visiting the [Documenso repository](https://github.com/documenso/documenso) and clicking on the 'Fork' button. (Also, star the repo!)
|
||||
|
||||

|
||||
|
||||
Choose your GitHub profile as the owner and click on 'Create fork' to create a fork of the repo.
|
||||
|
||||

|
||||
|
||||
### Step 2: Clone the forked repository and install dependencies
|
||||
|
||||
Clone the forked repository to your local machine in any directory of your choice. Open your terminal and enter the following commands:
|
||||
|
||||
```bash
|
||||
# Clone the repo using Github CLI
|
||||
gh repo clone [your_github_username]/documenso
|
||||
|
||||
# Clone the repo using Git
|
||||
git clone <https://github.com/[your_github_username]/documenso.git>
|
||||
```
|
||||
|
||||
You can now navigate into the directory and install the project’s dependencies:
|
||||
|
||||
```bash
|
||||
cd documenso
|
||||
npm install
|
||||
```
|
||||
|
||||
### Step 3: Create a new project on Supabase
|
||||
|
||||
Now, let's set up the database.
|
||||
|
||||
If you haven't already, create a new project on Supabase. This will automatically create a new Postgres database for you.
|
||||
|
||||
On your Supabase dashboard, click the '**New project**' button and choose your organization.
|
||||
|
||||
On the '**Create a new project**' page, set a database name of **documenso** and a secure password for your database. Choose a region closer to you, a pricing plan, and click on '**Create new project**' to create your project.
|
||||
|
||||

|
||||
|
||||
### Step 4: Copy the Supabase Postgres database connection URL
|
||||
|
||||
In your project, click the '**Settings**' icon at the bottom left.
|
||||
|
||||
Under the '**Project Settings**' section, click '**Database**' and scroll down to the '**Connection string**' section. Copy the '**URI**' and update it with the password you chose in the previous step.
|
||||
|
||||

|
||||
|
||||
### Step 5: Create a `.env` file
|
||||
|
||||
Create a `.env` file in the root of your project by copying the contents of the `.env.example` file.
|
||||
|
||||
Add the connection string you copied from your Supabase dashboard to the `DATABASE_URL` variable in the `.env` file.
|
||||
|
||||
The `.env` should look like this:
|
||||
|
||||
```bash
|
||||
DATABASE_URL="postgres://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres"
|
||||
```
|
||||
|
||||
### Step 6: Run the migration on the Supabase Postgres Database
|
||||
|
||||
Run the migration on the Supabase Postgres Database using the following command:
|
||||
|
||||
```bash
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### Step 7: Get your SMTP Keys on Resend
|
||||
|
||||
So, you've just cloned Documenso, installed dependencies on your local machine, and set your database using Supabase. Now, SMTP is missing. Emails won't go out! Let's fix it with Resend.
|
||||
|
||||
In the **[Resend](https://resend.com/)** dashboard, click 'Add API Key' to create a key for Resend SMTP.
|
||||
|
||||

|
||||
|
||||
Next, add and verify your domain in the '**Domains**' section on the sidebar. This will allow you to send emails from any address associated with your domain.
|
||||
|
||||

|
||||
|
||||
You can update your `.env` file with the following:
|
||||
|
||||
```jsx
|
||||
SMTP_MAIL_HOST = 'smtp.resend.com';
|
||||
SMTP_MAIL_PORT = '25';
|
||||
SMTP_MAIL_USER = 'resend';
|
||||
SMTP_MAIL_PASSWORD = 'YOUR_RESEND_API_KEY';
|
||||
MAIL_FROM = 'noreply@[YOUR_DOMAIN]';
|
||||
```
|
||||
|
||||
### Step 8: Create a new project on Vercel
|
||||
|
||||
You set the database with Supabase and are SMTP-ready with Resend. Almost there! The next step is to deploy the project — we'll use Vercel for that.
|
||||
|
||||
On your Vercel dashboard, create a new project using the forked project from your GitHub repositories. Select the project among the options and click '**Import**' to start running Documenso.
|
||||
|
||||

|
||||
|
||||
### Step 9: Add Environment Variables to your Vercel project
|
||||
|
||||
In the '**Configure Project**' page, adding the required Environmental Variables is essential to ensure the application deploys without any errors.
|
||||
|
||||
Specifically, for the `NEXT_PUBLIC_WEBAPP_URL` and `NEXTAUTH_URL` variables, you must add `.vercel.app` to your Project Name. This will form the deployment URL, which will be in the format: `https://[project_name].vercel.app`.
|
||||
|
||||
For example, in my case, the deployment URL is `https://documenso-supabase-web.vercel.app`.
|
||||
|
||||

|
||||
|
||||
This is a sample `.env` to deploy. Copy and paste it to auto-populate the fields and click ‘**Deploy.’** Now, you only need to wait for your project to deploy. You’re going live — enjoy!
|
||||
|
||||
```bash
|
||||
DATABASE_URL='postgresql://postgres:typeinastrongpassword@db.njuigobjlbteahssqbtw.supabase.co:5432/postgres'
|
||||
|
||||
NEXT_PUBLIC_WEBAPP_URL='https://documenso-supabase-web.vercel.app'
|
||||
NEXTAUTH_SECRET='something gibrish to encrypt your jwt tokens'
|
||||
NEXTAUTH_URL='https://documenso-supabase-web.vercel.app'
|
||||
|
||||
# Get a Sendgrid Api key here: <https://signup.sendgrid.com>
|
||||
SENDGRID_API_KEY=''
|
||||
|
||||
# Set SMTP credentials to use SMTP instead of the Sendgrid API.
|
||||
SMTP_MAIL_HOST='smtp.resend.com'
|
||||
SMTP_MAIL_PORT='25'
|
||||
SMTP_MAIL_USER='resend'
|
||||
SMTP_MAIL_PASSWORD='YOUR_RESEND_API_KEY'
|
||||
MAIL_FROM='noreply@[YOUR_DOMAIN]'
|
||||
|
||||
NEXT_PUBLIC_ALLOW_SIGNUP=true
|
||||
```
|
||||
|
||||
## Wrapping up
|
||||
|
||||

|
||||
|
||||
Congratulations! 🎉 You've successfully deployed Documenso using Vercel, Supabase, and Resend. You're now ready to create and sign your own documents with your self-hosted Documenso!
|
||||
|
||||
In this step-by-step guide, you learned how to:
|
||||
|
||||
- set up a Postgres database using Supabase,
|
||||
- install SMTP with Resend,
|
||||
- deploy your project with Vercel.
|
||||
|
||||
Over to you! How was the tutorial? If you enjoyed it, [please do share](https://twitter.com/documenso/status/1700141802693480482)! And if you have any questions or comments, please reach out to me on [Twitter / X](https://twitter.com/EphraimDuncan_) (DM open) or [Discord](https://documen.so/discord).
|
||||
|
||||
We're building an open-source alternative to DocuSign and welcome every contribution. Head over to the GitHub repository and [leave us a Star](https://github.com/documenso/documenso)!
|
||||
101
apps/marketing/content/blog/launch-week.mdx
Normal file
101
apps/marketing/content/blog/launch-week.mdx
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: A week of announcements
|
||||
description: An overview of what's new at Documenso.
|
||||
authorName: 'Flo Merian'
|
||||
authorImage: '/blog/blog-author-flo.jpeg'
|
||||
authorRole: 'Go-to-market'
|
||||
date: 2023-09-20
|
||||
tags:
|
||||
- Announcement
|
||||
- Community
|
||||
---
|
||||
|
||||
We just spent the week announcing something new every day. This blog post gives an overview of what's new at Documenso.
|
||||
|
||||
## A week of announcements
|
||||
|
||||
### Day 1: Documenso Design System
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/blog-fig-launch-week-documenso-design-system.webp"
|
||||
width="2000"
|
||||
height="1126"
|
||||
alt="Documenso Design System"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">Documenso Design System</figcaption>
|
||||
</figure>
|
||||
|
||||
We open-sourced Documenso's design system.
|
||||
|
||||
Documenso isn't just an open-source alternative to DocuSign. It's a beautiful document signing experience.
|
||||
|
||||
We welcome every developer AND designer to contribute to building the product.
|
||||
|
||||
[This Figma file]() serves as an open source guide for the design of the product across website, desktop & mobile apps, and brand.
|
||||
|
||||
It includes design tokens, primitives and components, screens, and brand assets.
|
||||
|
||||
Go duplicate and remix it!
|
||||
|
||||
### Day 2: Malfunction Mania
|
||||
|
||||
Documenso 1.0 just hit the testing environment.
|
||||
|
||||
We want to ensure the best possible release. That's why we started this week a public testing phase, the most significant community project yet, where we invite you to try out the new version, report and fix bugs and give feedback before release.
|
||||
|
||||
We call it "Malfunction Mania."
|
||||
|
||||
Early contributors love it so far:
|
||||
|
||||
"I love the refresh. The UI is much cleaner and the possibility to select the signature spot, date, name, email, and change the size, is a life-saver..."
|
||||
|
||||
Documenso's co-founder and CEO Timur shared the details in [this announcement]().
|
||||
|
||||
### Day 3: Documenso Shop
|
||||
|
||||
Supporting and contributing to open-source projects like Documenso is cool for sure.
|
||||
|
||||
Do you know what's *even* cooler tho? To wear swag from open-source projects.
|
||||
|
||||
We announced the [Documenso Shop](https://documen.so/shop), a place where you can buy some merch. Suit up!
|
||||
|
||||
### Day 4: Early Adopter Plan
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/blog-fig-launch-week-early-adopter-plan.webp"
|
||||
width="2000"
|
||||
height="1126"
|
||||
alt="Early Adopter Plan"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">The Early Adopter Plan. $30/month, forever. Available for the first 100 new signups.</figcaption>
|
||||
</figure>
|
||||
|
||||
As we ramp up development speed, we introduced the [Early Adopter Plan](https://documen.so/pricing), a special, fixed $30/mo offer for 100 early adopters who want to get deep hands-on feedback.
|
||||
|
||||
Timur published a blog post [here]() to explain how we plan to build the core version of [documenso.com](http://documenso.com/).
|
||||
|
||||
### Day 5: Upcoming Launches
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/blog-fig-launch-week-upcoming-launches.webp"
|
||||
width="2000"
|
||||
height="1126"
|
||||
alt="Documenso Upcoming Launches"
|
||||
/>
|
||||
|
||||
</figure>
|
||||
|
||||
Say hello! hey! yo! to the public roadmap.
|
||||
|
||||
Go to the repository to find it and preview what's next at Documenso, the features we're working on and upcoming launches.
|
||||
|
||||
Make sure to [star the repo on GitHub](https://documen.so/github) and watch it to get the latest updates.
|
||||
|
||||
## Wrapping up
|
||||
|
||||
So this week was Documenso's first Launch Week. We hope you enjoyed it as much as we did.
|
||||
@@ -8,9 +8,50 @@ const { parsed: env } = require('dotenv').config({
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
experimental: {
|
||||
serverActions: true,
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
|
||||
env,
|
||||
modularizeImports: {
|
||||
'lucide-react': {
|
||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'x-dns-prefetch-control',
|
||||
value: 'on',
|
||||
},
|
||||
{
|
||||
key: 'strict-transport-security',
|
||||
value: 'max-age=31536000; includeSubDomains; preload',
|
||||
},
|
||||
{
|
||||
key: 'x-frame-options',
|
||||
value: 'SAMEORIGIN',
|
||||
},
|
||||
{
|
||||
key: 'x-content-type-options',
|
||||
value: 'nosniff',
|
||||
},
|
||||
{
|
||||
key: 'referrer-policy',
|
||||
value: 'strict-origin-when-cross-origin',
|
||||
},
|
||||
{
|
||||
key: 'permissions-policy',
|
||||
value:
|
||||
'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = withContentlayer(config);
|
||||
|
||||
3
apps/marketing/process-env.d.ts
vendored
3
apps/marketing/process-env.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
declare namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
NEXT_PUBLIC_SITE_URL?: string;
|
||||
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||
|
||||
NEXT_PRIVATE_DATABASE_URL: string;
|
||||
|
||||
|
||||
BIN
apps/marketing/public/blog/blog-author-duncan.jpeg
Normal file
BIN
apps/marketing/public/blog/blog-author-duncan.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 291 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -19,6 +19,7 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||
|
||||
return {
|
||||
title: `Documenso - ${blogPost.title}`,
|
||||
description: blogPost.description,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
|
||||
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`}
|
||||
target="_blank"
|
||||
className="mt-4 block"
|
||||
>
|
||||
|
||||
@@ -21,12 +21,12 @@ export const metadata = {
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
type: 'website',
|
||||
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
|
||||
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
|
||||
},
|
||||
twitter: {
|
||||
site: '@documenso',
|
||||
card: 'summary_large_image',
|
||||
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
|
||||
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
},
|
||||
|
||||
@@ -43,7 +43,7 @@ export default async function handler(
|
||||
|
||||
if (user && user.Subscription.length > 0) {
|
||||
return res.status(200).json({
|
||||
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
||||
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ export default async function handler(
|
||||
mode: 'subscription',
|
||||
metadata,
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
||||
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/pricing?email=${encodeURIComponent(
|
||||
email,
|
||||
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
|
||||
});
|
||||
|
||||
@@ -8,8 +8,11 @@ import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in
|
||||
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
||||
import { redis } from '@documenso/lib/server-only/redis';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
@@ -85,16 +88,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
|
||||
|
||||
const { id: documentDataId } = await prisma.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: bytes64,
|
||||
initialData: bytes64,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title: 'Documenso Supporter Pledge.pdf',
|
||||
status: DocumentStatus.COMPLETED,
|
||||
userId: user.id,
|
||||
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'),
|
||||
created: now,
|
||||
documentDataId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
if (!documentData) {
|
||||
throw new Error(`Document ${document.id} has no document data`);
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
name: user.name ?? '',
|
||||
@@ -121,17 +142,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
});
|
||||
|
||||
let pdfData = await getFile(documentData).then((data) =>
|
||||
Buffer.from(data).toString('base64'),
|
||||
);
|
||||
|
||||
if (signatureDataUrl) {
|
||||
document.document = await insertImageInPDF(
|
||||
document.document,
|
||||
pdfData = await insertImageInPDF(
|
||||
pdfData,
|
||||
signatureDataUrl,
|
||||
Number(field.positionX),
|
||||
Number(field.positionY),
|
||||
field.page,
|
||||
);
|
||||
} else {
|
||||
document.document = await insertTextInPDF(
|
||||
document.document,
|
||||
pdfData = await insertTextInPDF(
|
||||
pdfData,
|
||||
signatureText ?? '',
|
||||
Number(field.positionX),
|
||||
Number(field.positionY),
|
||||
@@ -139,6 +164,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
);
|
||||
}
|
||||
|
||||
const { data: newData } = await updateFile({
|
||||
type: documentData.type,
|
||||
oldData: documentData.initialData,
|
||||
newData: Buffer.from(pdfData, 'base64').toString('binary'),
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
prisma.signature.create({
|
||||
data: {
|
||||
@@ -148,12 +179,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
typedSignature: signatureDataUrl ? '' : signatureText,
|
||||
},
|
||||
}),
|
||||
prisma.document.update({
|
||||
prisma.documentData.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
id: documentData.id,
|
||||
},
|
||||
data: {
|
||||
document: document.document,
|
||||
data: newData,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -10,6 +10,7 @@ const { parsed: env } = require('dotenv').config({
|
||||
const config = {
|
||||
experimental: {
|
||||
serverActions: true,
|
||||
serverActionsBodySizeLimit: '50mb',
|
||||
},
|
||||
reactStrictMode: true,
|
||||
transpilePackages: [
|
||||
@@ -20,7 +21,6 @@ const config = {
|
||||
'@documenso/email',
|
||||
],
|
||||
env: {
|
||||
...env,
|
||||
APP_VERSION: version,
|
||||
},
|
||||
modularizeImports: {
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"lucide-react": "^0.214.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.12",
|
||||
"next-auth": "4.22.3",
|
||||
"next-plausible": "^3.10.1",
|
||||
|
||||
3
apps/web/process-env.d.ts
vendored
3
apps/web/process-env.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
declare namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
NEXT_PUBLIC_SITE_URL?: string;
|
||||
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||
|
||||
NEXT_PRIVATE_DATABASE_URL: string;
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { TCreateDocumentRequestSchema, ZCreateDocumentResponseSchema } from './types';
|
||||
|
||||
export const useCreateDocument = () => {
|
||||
return useMutation(async ({ file }: TCreateDocumentRequestSchema) => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.set('file', file);
|
||||
|
||||
const response = await fetch('/api/document/create', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Failed to create document');
|
||||
}
|
||||
|
||||
const safeBody = ZCreateDocumentResponseSchema.safeParse(body);
|
||||
|
||||
if (!safeBody.success) {
|
||||
throw new Error('Failed to create document');
|
||||
}
|
||||
|
||||
if ('error' in safeBody.data) {
|
||||
throw new Error(safeBody.data.error);
|
||||
}
|
||||
|
||||
return safeBody.data;
|
||||
});
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateDocumentRequestSchema = z.object({
|
||||
file: z.instanceof(File),
|
||||
});
|
||||
|
||||
export type TCreateDocumentRequestSchema = z.infer<typeof ZCreateDocumentRequestSchema>;
|
||||
|
||||
export const ZCreateDocumentResponseSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
error: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
export type TCreateDocumentResponseSchema = z.infer<typeof ZCreateDocumentResponseSchema>;
|
||||
@@ -4,7 +4,8 @@ import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Document, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||
@@ -28,9 +29,10 @@ import { completeDocument } from '~/components/forms/edit-document/add-subject.a
|
||||
export type EditDocumentFormProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
document: Document;
|
||||
document: DocumentWithData;
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
type EditDocumentStep = 'signers' | 'fields' | 'subject';
|
||||
@@ -41,14 +43,13 @@ export const EditDocumentForm = ({
|
||||
recipients,
|
||||
fields,
|
||||
user: _user,
|
||||
dataUrl,
|
||||
}: EditDocumentFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState<EditDocumentStep>('signers');
|
||||
|
||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||
|
||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||
signers: {
|
||||
title: 'Add Signers',
|
||||
@@ -151,11 +152,11 @@ export const EditDocumentForm = ({
|
||||
return (
|
||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||
<Card
|
||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer document={documentUrl} />
|
||||
<LazyPDFViewer document={dataUrl} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { PDFViewerProps } from '@documenso/ui/primitives/pdf-viewer';
|
||||
|
||||
export type LoadablePDFCard = PDFViewerProps & {
|
||||
className?: string;
|
||||
pdfClassName?: string;
|
||||
};
|
||||
|
||||
export const LoadablePDFCard = ({ className, pdfClassName, ...props }: LoadablePDFCard) => {
|
||||
return (
|
||||
<Card className={className} gradient {...props}>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer className={pdfClassName} {...props} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
@@ -36,10 +37,16 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
userId: session.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
if (!document || !document.documentData) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
const documentDataUrl = await getFile(documentData)
|
||||
.then((buffer) => Buffer.from(buffer).toString('base64'))
|
||||
.then((data) => `data:application/pdf;base64,${data}`);
|
||||
|
||||
const [recipients, fields] = await Promise.all([
|
||||
await getRecipientsForDocument({
|
||||
documentId,
|
||||
@@ -86,12 +93,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
user={session}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
dataUrl={documentDataUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} />
|
||||
<LazyPDFViewer document={documentDataUrl} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc } from '@documenso/trpc/client';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -47,17 +50,26 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
|
||||
const onDownloadClick = () => {
|
||||
let decodedDocument = row.document;
|
||||
const onDownloadClick = async () => {
|
||||
let document: DocumentWithData | null = null;
|
||||
|
||||
try {
|
||||
decodedDocument = atob(decodedDocument);
|
||||
} catch (err) {
|
||||
// We're just going to ignore this error and try to download the document
|
||||
console.error(err);
|
||||
if (!recipient) {
|
||||
document = await trpc.document.getDocumentById.query({
|
||||
id: row.id,
|
||||
});
|
||||
} else {
|
||||
document = await trpc.document.getDocumentByToken.query({
|
||||
token: recipient.token,
|
||||
});
|
||||
}
|
||||
|
||||
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
|
||||
const documentData = document?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentBytes = await getFile(documentData);
|
||||
|
||||
const blob = new Blob([documentBytes], {
|
||||
type: 'application/pdf',
|
||||
|
||||
@@ -53,8 +53,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
columns={[
|
||||
{
|
||||
header: 'Created',
|
||||
accessorKey: 'created',
|
||||
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
|
||||
accessorKey: 'createdAt',
|
||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||
},
|
||||
{
|
||||
header: 'Title',
|
||||
|
||||
@@ -39,7 +39,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
||||
userId: user.id,
|
||||
status,
|
||||
orderBy: {
|
||||
column: 'created',
|
||||
column: 'createdAt',
|
||||
direction: 'desc',
|
||||
},
|
||||
page,
|
||||
|
||||
@@ -1,29 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCreateDocument } from '~/api/document/create/fetcher';
|
||||
|
||||
export type UploadDocumentProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const { isLoading, mutateAsync: createDocument } = useCreateDocument();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const { type, data } = await putFile(file);
|
||||
|
||||
const { id: documentDataId } = await createDocumentData({
|
||||
type,
|
||||
data,
|
||||
});
|
||||
|
||||
const { id } = await createDocument({
|
||||
file: file,
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -41,6 +57,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
description: 'An error occurred while uploading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,7 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
||||
<Loader className="h-12 w-12 animate-spin text-slate-500" />
|
||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -21,19 +21,21 @@ export default async function BillingSettingsPage() {
|
||||
redirect('/settings/profile');
|
||||
}
|
||||
|
||||
let subscription = await getSubscriptionByUserId({ userId: user.id });
|
||||
const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
|
||||
if (sub) {
|
||||
return sub;
|
||||
}
|
||||
|
||||
// If we don't have a customer record, create one as well as an empty subscription.
|
||||
if (!subscription?.customerId) {
|
||||
subscription = await createCustomer({ user });
|
||||
}
|
||||
// If we don't have a customer record, create one as well as an empty subscription.
|
||||
return createCustomer({ user });
|
||||
});
|
||||
|
||||
let billingPortalUrl = '';
|
||||
|
||||
if (subscription?.customerId) {
|
||||
if (subscription.customerId) {
|
||||
billingPortalUrl = await getPortalSession({
|
||||
customerId: subscription.customerId,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
|
||||
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,7 +43,7 @@ export default async function BillingSettingsPage() {
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Billing</h3>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Your subscription is{' '}
|
||||
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
|
||||
{subscription?.periodEnd && (
|
||||
@@ -65,7 +67,7 @@ export default async function BillingSettingsPage() {
|
||||
)}
|
||||
|
||||
{!billingPortalUrl && (
|
||||
<p className="max-w-[60ch] text-base text-slate-500">
|
||||
<p className="text-muted-foreground max-w-[60ch] text-base">
|
||||
You do not currently have a customer record, this should not happen. Please contact
|
||||
support for assistance.
|
||||
</p>
|
||||
|
||||
@@ -9,7 +9,7 @@ export default async function PasswordSettingsPage() {
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Password</h3>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">Here you can update your password.</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export default async function ProfileSettingsPage() {
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Profile</h3>
|
||||
|
||||
<p className="mt-2 text-sm text-slate-500">Here you can edit your personal details.</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
|
||||
@@ -1,55 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { DocumentData } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
disabled?: boolean;
|
||||
fileName?: string;
|
||||
document?: string;
|
||||
documentData?: DocumentData;
|
||||
};
|
||||
|
||||
export const DownloadButton = ({
|
||||
className,
|
||||
fileName,
|
||||
document,
|
||||
documentData,
|
||||
disabled,
|
||||
...props
|
||||
}: DownloadButtonProps) => {
|
||||
/**
|
||||
* Convert the document from base64 to a blob and download it.
|
||||
*/
|
||||
const onDownloadClick = () => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
const { toast } = useToast();
|
||||
|
||||
let decodedDocument = document;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
decodedDocument = atob(document);
|
||||
setIsLoading(true);
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = await getFile(documentData);
|
||||
|
||||
const blob = new Blob([bytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = fileName || 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
} catch (err) {
|
||||
// We're just going to ignore this error and try to download the document
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while downloading your document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
|
||||
|
||||
const blob = new Blob([documentBytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = fileName || 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -57,8 +66,9 @@ export const DownloadButton = ({
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={className}
|
||||
disabled={disabled || !document}
|
||||
disabled={disabled || !documentData}
|
||||
onClick={onDownloadClick}
|
||||
loading={isLoading}
|
||||
{...props}
|
||||
>
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
|
||||
@@ -30,15 +30,21 @@ export default async function CompletedSigningPage({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
if (!document || !document.documentData) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
]);
|
||||
|
||||
if (!recipient) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const recipientName =
|
||||
recipient.name ||
|
||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
@@ -91,7 +97,7 @@ export default async function CompletedSigningPage({
|
||||
<DownloadButton
|
||||
className="flex-1"
|
||||
fileName={document.title}
|
||||
document={document.status === DocumentStatus.COMPLETED ? document.document : undefined}
|
||||
documentData={documentData}
|
||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
@@ -36,17 +37,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
token,
|
||||
}).catch(() => null),
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
viewedDocument({ token }),
|
||||
]);
|
||||
|
||||
if (!document) {
|
||||
if (!document || !document.documentData || !recipient) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const user = await getServerComponentSession();
|
||||
const { documentData } = document;
|
||||
|
||||
const documentUrl = `data:application/pdf;base64,${document.document}`;
|
||||
const documentDataUrl = await getFile(documentData)
|
||||
.then((buffer) => Buffer.from(buffer).toString('base64'))
|
||||
.then((data) => `data:application/pdf;base64,${data}`);
|
||||
|
||||
const user = await getServerComponentSession();
|
||||
|
||||
return (
|
||||
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
|
||||
@@ -67,7 +72,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer document={documentUrl} />
|
||||
<LazyPDFViewer document={documentDataUrl} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
20
apps/web/src/app/(unauthenticated)/check-email/page.tsx
Normal file
20
apps/web/src/app/(unauthenticated)/check-email/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Email sent!</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||
A password reset email has been sent, if you have an account you should see it in your inbox
|
||||
shortly.
|
||||
</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href="/signin">Return to sign in</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
Normal file
25
apps/web/src/app/(unauthenticated)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Forgotten your password?</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
No worries, it happens! Enter your email and we'll email you a special link to reset your
|
||||
password.
|
||||
</p>
|
||||
|
||||
<ForgotPasswordForm className="mt-4" />
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Remembered your password?{' '}
|
||||
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
apps/web/src/app/(unauthenticated)/layout.tsx
Normal file
27
apps/web/src/app/(unauthenticated)/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import backgroundPattern from '~/assets/background-pattern.png';
|
||||
|
||||
type UnauthenticatedLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
|
||||
return (
|
||||
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div className="relative flex w-full max-w-md items-center gap-x-24">
|
||||
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
||||
<Image
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="dark:brightness-95 dark:invert dark:sepia"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity';
|
||||
|
||||
import { ResetPasswordForm } from '~/components/forms/reset-password';
|
||||
|
||||
type ResetPasswordPageProps = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function ResetPasswordPage({ params: { token } }: ResetPasswordPageProps) {
|
||||
const isValid = await getResetTokenValidity({ token });
|
||||
|
||||
if (!isValid) {
|
||||
redirect('/reset-password');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h1 className="text-4xl font-semibold">Reset Password</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">Please choose your new password </p>
|
||||
|
||||
<ResetPasswordForm token={token} className="mt-4" />
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/app/(unauthenticated)/reset-password/page.tsx
Normal file
20
apps/web/src/app/(unauthenticated)/reset-password/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Unable to reset password</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
The token you have used to reset your password is either expired or it never existed. If you
|
||||
have still forgotten your password, please request a new reset link.
|
||||
</p>
|
||||
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/signin">Return to sign in</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,33 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import backgroundPattern from '~/assets/background-pattern.png';
|
||||
import connections from '~/assets/card-sharing-figure.png';
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div className="relative flex max-w-4xl items-center gap-x-24">
|
||||
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
||||
<Image
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="dark:brightness-95 dark:invert dark:sepia"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||
|
||||
<div className="max-w-md">
|
||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||
Welcome back, we are lucky to have you.
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||
Welcome back, we are lucky to have you.
|
||||
</p>
|
||||
<SignInForm className="mt-4" />
|
||||
|
||||
<SignInForm className="mt-4" />
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 lg:block">
|
||||
<Image src={connections} alt="documenso connections" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<p className="mt-2.5 text-center">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
||||
>
|
||||
Forgotten your password?
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,25 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import backgroundPattern from '~/assets/background-pattern.png';
|
||||
import connections from '~/assets/connections.png';
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div className="relative flex max-w-4xl items-center gap-x-24">
|
||||
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
|
||||
<Image
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="dark:brightness-95 dark:invert dark:sepia"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||
|
||||
<div className="max-w-md">
|
||||
<h1 className="text-4xl font-semibold">Create a shiny, new Documenso Account ✨</h1>
|
||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||
Create your account and start using state-of-the-art document signing. Open and beautiful
|
||||
signing is within your grasp.
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||
Create your account and start using state-of-the-art document signing. Open and
|
||||
beautiful signing is within your grasp.
|
||||
</p>
|
||||
<SignUpForm className="mt-4" />
|
||||
|
||||
<SignUpForm className="mt-4" />
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Already have an account?{' '}
|
||||
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign in instead
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 lg:block">
|
||||
<Image src={connections} alt="documenso connections" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Already have an account?{' '}
|
||||
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||
Sign in instead
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,12 +33,12 @@ export const metadata = {
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
type: 'website',
|
||||
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
|
||||
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
|
||||
},
|
||||
twitter: {
|
||||
site: '@documenso',
|
||||
card: 'summary_large_image',
|
||||
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
|
||||
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
@@ -17,10 +17,23 @@ export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
};
|
||||
|
||||
export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
setScrollY(window.scrollY);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', onScroll);
|
||||
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b backdrop-blur',
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||
scrollY > 5 && 'border-b-border',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const PeriodSelector = () => {
|
||||
|
||||
return (
|
||||
<Select defaultValue={period} onValueChange={onPeriodChange}>
|
||||
<SelectTrigger className="max-w-[200px] text-slate-500">
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
|
||||
80
apps/web/src/components/forms/forgot-password.tsx
Normal file
80
apps/web/src/components/forms/forgot-password.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZForgotPasswordFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
|
||||
export type ForgotPasswordFormProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TForgotPasswordFormSchema>({
|
||||
values: {
|
||||
email: '',
|
||||
},
|
||||
resolver: zodResolver(ZForgotPasswordFormSchema),
|
||||
});
|
||||
|
||||
const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => {
|
||||
await forgotPassword({ email }).catch(() => null);
|
||||
|
||||
toast({
|
||||
title: 'Reset email sent',
|
||||
description:
|
||||
'A password reset email has been sent, if you have an account you should see it in your inbox shortly.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
reset();
|
||||
|
||||
router.push('/check-email');
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
|
||||
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.email} />
|
||||
</div>
|
||||
|
||||
<Button size="lg" loading={isSubmitting}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Eye, EyeOff, Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -36,6 +38,9 @@ export type PasswordFormProps = {
|
||||
export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -88,37 +93,69 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-slate-500">
|
||||
<Label htmlFor="password" className="text-muted-foreground">
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="repeated-password" className="text-slate-500">
|
||||
<Label htmlFor="repeated-password" className="text-muted-foreground">
|
||||
Repeat Password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="repeated-password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('repeatedPassword')}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="repeated-password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('repeatedPassword')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowConfirmPassword((show) => !show)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="full-name" className="text-slate-500">
|
||||
<Label htmlFor="full-name" className="text-muted-foreground">
|
||||
Full Name
|
||||
</Label>
|
||||
|
||||
@@ -99,7 +99,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-slate-500">
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
|
||||
@@ -107,7 +107,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="signature" className="text-slate-500">
|
||||
<Label htmlFor="signature" className="text-muted-foreground">
|
||||
Signature
|
||||
</Label>
|
||||
|
||||
|
||||
173
apps/web/src/components/forms/reset-password.tsx
Normal file
173
apps/web/src/components/forms/reset-password.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZResetPasswordFormSchema = z
|
||||
.object({
|
||||
password: z.string().min(6).max(72),
|
||||
repeatedPassword: z.string().min(6).max(72),
|
||||
})
|
||||
.refine((data) => data.password === data.repeatedPassword, {
|
||||
path: ['repeatedPassword'],
|
||||
message: "Passwords don't match",
|
||||
});
|
||||
|
||||
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||
|
||||
export type ResetPasswordFormProps = {
|
||||
className?: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TResetPasswordFormSchema>({
|
||||
values: {
|
||||
password: '',
|
||||
repeatedPassword: '',
|
||||
},
|
||||
resolver: zodResolver(ZResetPasswordFormSchema),
|
||||
});
|
||||
|
||||
const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ password }: Omit<TResetPasswordFormSchema, 'repeatedPassword'>) => {
|
||||
try {
|
||||
await resetPassword({
|
||||
password,
|
||||
token,
|
||||
});
|
||||
|
||||
reset();
|
||||
|
||||
toast({
|
||||
title: 'Password updated',
|
||||
description: 'Your password has been updated successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/signin');
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||
toast({
|
||||
title: 'An error occurred',
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to reset your password. Please try again later.',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-muted-foreground">
|
||||
<span>Password</span>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="repeatedPassword" className="text-muted-foreground">
|
||||
<span>Repeat Password</span>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="repeated-password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('repeatedPassword')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowConfirmPassword((show) => !show)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
|
||||
</div>
|
||||
|
||||
<Button size="lg" loading={isSubmitting}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Eye, EyeOff, Loader } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
@@ -14,6 +14,7 @@ import { z } from 'zod';
|
||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
@@ -42,6 +43,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { toast } = useToast();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -113,33 +115,47 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
onSubmit={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-slate-500">
|
||||
<Label htmlFor="email" className="text-muted-forground">
|
||||
Email
|
||||
</Label>
|
||||
|
||||
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
|
||||
|
||||
{errors.email && <span className="mt-1 text-xs text-red-500">{errors.email.message}</span>}
|
||||
<FormErrorMessage className="mt-1.5" error={errors.email} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-slate-500">
|
||||
Password
|
||||
<Label htmlFor="password" className="text-muted-forground">
|
||||
<span>Password</span>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="current-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
{errors.password && (
|
||||
<span className="mt-1 text-xs text-red-500">{errors.password.message}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||
</div>
|
||||
|
||||
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { Eye, EyeOff, Loader } from 'lucide-react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
@@ -31,6 +33,7 @@ export type SignUpFormProps = {
|
||||
|
||||
export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -106,15 +109,31 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2"
|
||||
{...register('password')}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minLength={6}
|
||||
maxLength={72}
|
||||
autoComplete="new-password"
|
||||
className="bg-background mt-2 pr-10"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="text-muted-foreground h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const getFlag = async (
|
||||
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
||||
}
|
||||
|
||||
const url = new URL(`${process.env.NEXT_PUBLIC_SITE_URL}/api/feature-flag/get`);
|
||||
const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/get`);
|
||||
url.searchParams.set('flag', flag);
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -54,7 +54,7 @@ export const getAllFlags = async (
|
||||
return LOCAL_FEATURE_FLAGS;
|
||||
}
|
||||
|
||||
const url = new URL(`${process.env.NEXT_PUBLIC_SITE_URL}/api/feature-flag/all`);
|
||||
const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/all`);
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
|
||||
@@ -43,7 +43,7 @@ export default async function handler(
|
||||
|
||||
if (user && user.Subscription.length > 0) {
|
||||
return res.status(200).json({
|
||||
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/login`,
|
||||
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ export default async function handler(
|
||||
mode: 'subscription',
|
||||
metadata,
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
||||
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/pricing?email=${encodeURIComponent(
|
||||
email,
|
||||
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
|
||||
});
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import formidable, { type File } from 'formidable';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import {
|
||||
TCreateDocumentRequestSchema,
|
||||
TCreateDocumentResponseSchema,
|
||||
} from '~/api/document/create/types';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export type TFormidableCreateDocumentRequestSchema = {
|
||||
file: File;
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<TCreateDocumentResponseSchema>,
|
||||
) {
|
||||
const user = await getServerSession({ req, res });
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const form = formidable();
|
||||
|
||||
const { file } = await new Promise<TFormidableCreateDocumentRequestSchema>(
|
||||
(resolve, reject) => {
|
||||
form.parse(req, (err, fields, files) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
|
||||
// We had intended to do this with Zod but we can only validate it
|
||||
// as a persistent file which does not include the properties that we
|
||||
// need.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
resolve({ ...fields, ...files } as any);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const fileBuffer = readFileSync(file.filepath);
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title: file.originalFilename ?? file.newFilename,
|
||||
status: DocumentStatus.DRAFT,
|
||||
userId: user.id,
|
||||
document: fileBuffer.toString('base64'),
|
||||
created: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
id: document.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a hack to ensure that the types are correct.
|
||||
*/
|
||||
type FormidableSatisfiesCreateDocument =
|
||||
keyof TCreateDocumentRequestSchema extends keyof TFormidableCreateDocumentRequestSchema
|
||||
? true
|
||||
: never;
|
||||
|
||||
true satisfies FormidableSatisfiesCreateDocument;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { JWT, getToken } from 'next-auth/jwt';
|
||||
|
||||
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
|
||||
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { redis } from '@documenso/lib/server-only/redis';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentDataType,
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
@@ -85,16 +86,34 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
|
||||
|
||||
const { id: documentDataId } = await prisma.documentData.create({
|
||||
data: {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: bytes64,
|
||||
initialData: bytes64,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title: 'Documenso Supporter Pledge.pdf',
|
||||
status: DocumentStatus.COMPLETED,
|
||||
userId: user.id,
|
||||
document: readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64'),
|
||||
created: now,
|
||||
documentDataId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
if (!documentData) {
|
||||
throw new Error(`Document ${document.id} has no document data`);
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
name: user.name ?? '',
|
||||
@@ -122,16 +141,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
});
|
||||
|
||||
if (signatureDataUrl) {
|
||||
document.document = await insertImageInPDF(
|
||||
document.document,
|
||||
documentData.data = await insertImageInPDF(
|
||||
documentData.data,
|
||||
signatureDataUrl,
|
||||
field.positionX.toNumber(),
|
||||
field.positionY.toNumber(),
|
||||
field.page,
|
||||
);
|
||||
} else {
|
||||
document.document = await insertTextInPDF(
|
||||
document.document,
|
||||
documentData.data = await insertTextInPDF(
|
||||
documentData.data,
|
||||
signatureText ?? '',
|
||||
field.positionX.toNumber(),
|
||||
field.positionY.toNumber(),
|
||||
@@ -153,7 +172,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
id: document.id,
|
||||
},
|
||||
data: {
|
||||
document: document.document,
|
||||
documentData: {
|
||||
update: {
|
||||
data: documentData.data,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
BIN
assets/example.pdf
Normal file
BIN
assets/example.pdf
Normal file
Binary file not shown.
3198
package-lock.json
generated
3198
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,13 +16,13 @@
|
||||
"worker:test": "tsup worker/index.ts --format esm"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/tsconfig": "*",
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@react-email/components": "^0.0.7",
|
||||
"nodemailer": "^6.9.3"
|
||||
"nodemailer": "^6.9.3",
|
||||
"react-email": "^1.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/nodemailer": "^6.4.8",
|
||||
"tsup": "^7.1.0"
|
||||
}
|
||||
|
||||
@@ -4,8 +4,5 @@ const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
content: [
|
||||
`templates/**/*.{ts,tsx}`,
|
||||
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
|
||||
],
|
||||
content: [`templates/**/*.{ts,tsx}`],
|
||||
};
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { Link, Section, Text } from '@react-email/components';
|
||||
|
||||
export const TemplateFooter = () => {
|
||||
export type TemplateFooterProps = {
|
||||
isDocument?: boolean;
|
||||
};
|
||||
|
||||
export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
return (
|
||||
<Section>
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
This document was sent using{' '}
|
||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||
Documenso.
|
||||
</Link>
|
||||
</Text>
|
||||
{isDocument && (
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
This document was sent using{' '}
|
||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||
Documenso.
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text className="my-8 text-sm text-slate-400">
|
||||
Documenso
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
export type TemplateForgotPasswordProps = {
|
||||
resetPasswordLink: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const TemplateForgotPassword = ({
|
||||
resetPasswordLink,
|
||||
assetBaseUrl,
|
||||
}: TemplateForgotPasswordProps) => {
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Section className="mt-4 flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||
</div>
|
||||
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
Forgot your password?
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
That's okay, it happens! Click the button below to reset your password.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={resetPasswordLink}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateForgotPassword;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Img, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
export interface TemplateResetPasswordProps {
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
assetBaseUrl: string;
|
||||
}
|
||||
|
||||
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Section className="mt-4 flex-row items-center justify-center">
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
||||
</div>
|
||||
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
Password updated!
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Your password has been updated.
|
||||
</Text>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateResetPassword;
|
||||
74
packages/email/templates/forgot-password.tsx
Normal file
74
packages/email/templates/forgot-password.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import TemplateFooter from '../template-components/template-footer';
|
||||
import {
|
||||
TemplateForgotPassword,
|
||||
TemplateForgotPasswordProps,
|
||||
} from '../template-components/template-forgot-password';
|
||||
|
||||
export type ForgotPasswordTemplateProps = Partial<TemplateForgotPasswordProps>;
|
||||
|
||||
export const ForgotPasswordTemplate = ({
|
||||
resetPasswordLink = 'https://documenso.com',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: ForgotPasswordTemplateProps) => {
|
||||
const previewText = `Password Reset Requested`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
|
||||
<TemplateForgotPassword
|
||||
resetPasswordLink={resetPasswordLink}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<div className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordTemplate;
|
||||
102
packages/email/templates/reset-password.tsx
Normal file
102
packages/email/templates/reset-password.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import TemplateFooter from '../template-components/template-footer';
|
||||
import {
|
||||
TemplateResetPassword,
|
||||
TemplateResetPasswordProps,
|
||||
} from '../template-components/template-reset-password';
|
||||
|
||||
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps>;
|
||||
|
||||
export const ResetPasswordTemplate = ({
|
||||
userName = 'Lucas Smith',
|
||||
userEmail = 'lucas@documenso.com',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: ResetPasswordTemplateProps) => {
|
||||
const previewText = `Password Reset Successful`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
|
||||
<TemplateResetPassword
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto mt-12 max-w-xl">
|
||||
<Section>
|
||||
<Text className="my-4 text-base font-semibold">
|
||||
Hi, {userName}{' '}
|
||||
<Link className="font-normal text-slate-400" href={`mailto:${userEmail}`}>
|
||||
({userEmail})
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
We've changed your password as you asked. You can now sign in with your new
|
||||
password.
|
||||
</Text>
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
Didn't request a password change? We are here to help you secure your account,
|
||||
just{' '}
|
||||
<Link className="text-documenso-700 font-normal" href="mailto:hi@documenso.com">
|
||||
contact us.
|
||||
</Link>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordTemplate;
|
||||
5
packages/lib/constants/time.ts
Normal file
5
packages/lib/constants/time.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const ONE_SECOND = 1000;
|
||||
export const ONE_MINUTE = ONE_SECOND * 60;
|
||||
export const ONE_HOUR = ONE_MINUTE * 60;
|
||||
export const ONE_DAY = ONE_HOUR * 24;
|
||||
export const ONE_WEEK = ONE_DAY * 7;
|
||||
@@ -12,10 +12,15 @@
|
||||
],
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.410.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
||||
"@documenso/email": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@next-auth/prisma-adapter": "1.0.7",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@scure/base": "^1.1.3",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"bcrypt": "^5.1.0",
|
||||
"luxon": "^3.4.0",
|
||||
|
||||
53
packages/lib/server-only/auth/send-forgot-password.ts
Normal file
53
packages/lib/server-only/auth/send-forgot-password.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface SendForgotPasswordOptions {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
PasswordResetToken: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const token = user.PasswordResetToken[0].token;
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const resetPasswordLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/reset-password/${token}`;
|
||||
|
||||
const template = createElement(ForgotPasswordTemplate, {
|
||||
assetBaseUrl,
|
||||
resetPasswordLink,
|
||||
});
|
||||
|
||||
return await mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Forgot Password?',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
42
packages/lib/server-only/auth/send-reset-password.ts
Normal file
42
packages/lib/server-only/auth/send-reset-password.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface SendResetPasswordOptions {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
|
||||
console.log({ assetBaseUrl });
|
||||
|
||||
const template = createElement(ResetPasswordTemplate, {
|
||||
assetBaseUrl,
|
||||
userEmail: user.email,
|
||||
userName: user.name || '',
|
||||
});
|
||||
|
||||
return await mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Password Reset Success!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentDataType } from '@documenso/prisma/client';
|
||||
|
||||
export type CreateDocumentDataOptions = {
|
||||
type: DocumentDataType;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => {
|
||||
return await prisma.documentData.create({
|
||||
data: {
|
||||
type,
|
||||
data,
|
||||
initialData: data,
|
||||
},
|
||||
});
|
||||
};
|
||||
19
packages/lib/server-only/document/create-document.ts
Normal file
19
packages/lib/server-only/document/create-document.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
title: string;
|
||||
userId: number;
|
||||
documentDataId: string;
|
||||
};
|
||||
|
||||
export const createDocument = async ({ userId, title, documentDataId }: CreateDocumentOptions) => {
|
||||
return await prisma.document.create({
|
||||
data: {
|
||||
title,
|
||||
documentDataId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -32,7 +32,7 @@ export const findDocuments = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const orderByColumn = orderBy?.column ?? 'created';
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const termFilters = !term
|
||||
|
||||
@@ -11,5 +11,8 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const getDocumentAndSenderByToken = async ({
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
'use server';
|
||||
|
||||
import path from 'node:path';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
|
||||
export type SealDocumentOptions = {
|
||||
@@ -18,8 +21,17 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
if (!documentData) {
|
||||
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`);
|
||||
}
|
||||
@@ -48,7 +60,7 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
||||
}
|
||||
|
||||
// !: Need to write the fields onto the document as a hard copy
|
||||
const { document: pdfData } = document;
|
||||
const pdfData = await getFile(documentData);
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
@@ -58,13 +70,20 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
||||
|
||||
const pdfBytes = await doc.save();
|
||||
|
||||
await prisma.document.update({
|
||||
const { name, ext } = path.parse(document.title);
|
||||
|
||||
const { data: newData } = await putFile({
|
||||
name: `${name}_signed${ext}`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(Buffer.from(pdfBytes)),
|
||||
});
|
||||
|
||||
await prisma.documentData.update({
|
||||
where: {
|
||||
id: document.id,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
id: documentData.id,
|
||||
},
|
||||
data: {
|
||||
document: Buffer.from(pdfBytes).toString('base64'),
|
||||
data: newData,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -48,8 +48,8 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
||||
return;
|
||||
}
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||
const signDocumentLink = `${process.env.NEXT_PUBLIC_SITE_URL}/sign/${recipient.token}`;
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { nanoid } from '../../universal/id';
|
||||
|
||||
export interface SetRecipientsForDocumentOptions {
|
||||
userId: number;
|
||||
documentId: number;
|
||||
|
||||
53
packages/lib/server-only/user/forgot-password.ts
Normal file
53
packages/lib/server-only/user/forgot-password.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TForgotPasswordFormSchema } from '@documenso/trpc/server/profile-router/schema';
|
||||
|
||||
import { ONE_DAY, ONE_HOUR } from '../../constants/time';
|
||||
import { sendForgotPassword } from '../auth/send-forgot-password';
|
||||
|
||||
export const forgotPassword = async ({ email }: TForgotPasswordFormSchema) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a token that was created in the last hour and hasn't expired
|
||||
const existingToken = await prisma.passwordResetToken.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
expiry: {
|
||||
gt: new Date(),
|
||||
},
|
||||
createdAt: {
|
||||
gt: new Date(Date.now() - ONE_HOUR),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(18).toString('hex');
|
||||
|
||||
await prisma.passwordResetToken.create({
|
||||
data: {
|
||||
token,
|
||||
expiry: new Date(Date.now() + ONE_DAY),
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await sendForgotPassword({
|
||||
userId: user.id,
|
||||
}).catch((err) => console.error(err));
|
||||
};
|
||||
19
packages/lib/server-only/user/get-reset-token-validity.ts
Normal file
19
packages/lib/server-only/user/get-reset-token-validity.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
type GetResetTokenValidityOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const getResetTokenValidity = async ({ token }: GetResetTokenValidityOptions) => {
|
||||
const found = await prisma.passwordResetToken.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
expiry: true,
|
||||
},
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
return !!found && found.expiry > new Date();
|
||||
};
|
||||
62
packages/lib/server-only/user/reset-password.ts
Normal file
62
packages/lib/server-only/user/reset-password.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { compare, hash } from 'bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
import { sendResetPassword } from '../auth/send-reset-password';
|
||||
|
||||
export type ResetPasswordOptions = {
|
||||
token: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export const resetPassword = async ({ token, password }: ResetPasswordOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Invalid token provided. Please try again.');
|
||||
}
|
||||
|
||||
const foundToken = await prisma.passwordResetToken.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!foundToken) {
|
||||
throw new Error('Invalid token provided. Please try again.');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (now > foundToken.expiry) {
|
||||
throw new Error('Token has expired. Please try again.');
|
||||
}
|
||||
|
||||
const isSamePassword = await compare(password, foundToken.User.password || '');
|
||||
|
||||
if (isSamePassword) {
|
||||
throw new Error('Your new password cannot be the same as your old password.');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: {
|
||||
id: foundToken.userId,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
}),
|
||||
prisma.passwordResetToken.deleteMany({
|
||||
where: {
|
||||
userId: foundToken.userId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await sendResetPassword({ userId: foundToken.userId });
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"extends": "@documenso/tsconfig/react-library.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@documenso/tsconfig/process-env.d.ts"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ export const getBaseUrl = () => {
|
||||
return `https://${process.env.VERCEL_URL}`;
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_SITE_URL) {
|
||||
return `https://${process.env.NEXT_PUBLIC_SITE_URL}`;
|
||||
if (process.env.NEXT_PUBLIC_WEBAPP_URL) {
|
||||
return process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||
}
|
||||
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
|
||||
5
packages/lib/universal/id.ts
Normal file
5
packages/lib/universal/id.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10);
|
||||
|
||||
export { nanoid } from 'nanoid';
|
||||
22
packages/lib/universal/upload/delete-file.ts
Normal file
22
packages/lib/universal/upload/delete-file.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentDataType } from '@documenso/prisma/client';
|
||||
|
||||
import { deleteS3File } from './server-actions';
|
||||
|
||||
export type DeleteFileOptions = {
|
||||
type: DocumentDataType;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const deleteFile = async ({ type, data }: DeleteFileOptions) => {
|
||||
return await match(type)
|
||||
.with(DocumentDataType.S3_PATH, async () => deleteFileFromS3(data))
|
||||
.otherwise(() => {
|
||||
return;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFileFromS3 = async (key: string) => {
|
||||
await deleteS3File(key);
|
||||
};
|
||||
51
packages/lib/universal/upload/get-file.ts
Normal file
51
packages/lib/universal/upload/get-file.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { base64 } from '@scure/base';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentDataType } from '@documenso/prisma/client';
|
||||
|
||||
import { getPresignGetUrl } from './server-actions';
|
||||
|
||||
export type GetFileOptions = {
|
||||
type: DocumentDataType;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const getFile = async ({ type, data }: GetFileOptions) => {
|
||||
return await match(type)
|
||||
.with(DocumentDataType.BYTES, () => getFileFromBytes(data))
|
||||
.with(DocumentDataType.BYTES_64, () => getFileFromBytes64(data))
|
||||
.with(DocumentDataType.S3_PATH, async () => getFileFromS3(data))
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const getFileFromBytes = (data: string) => {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const binaryData = encoder.encode(data);
|
||||
|
||||
return binaryData;
|
||||
};
|
||||
|
||||
const getFileFromBytes64 = (data: string) => {
|
||||
const binaryData = base64.decode(data);
|
||||
|
||||
return binaryData;
|
||||
};
|
||||
|
||||
const getFileFromS3 = async (key: string) => {
|
||||
const { url } = await getPresignGetUrl(key);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get file "${key}", failed with status code ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
const binaryData = new Uint8Array(buffer);
|
||||
|
||||
return binaryData;
|
||||
};
|
||||
59
packages/lib/universal/upload/put-file.ts
Normal file
59
packages/lib/universal/upload/put-file.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { base64 } from '@scure/base';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentDataType } from '@documenso/prisma/client';
|
||||
|
||||
import { createDocumentData } from '../../server-only/document-data/create-document-data';
|
||||
import { getPresignPostUrl } from './server-actions';
|
||||
|
||||
type File = {
|
||||
name: string;
|
||||
type: string;
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
export const putFile = async (file: File) => {
|
||||
const { type, data } = await match(process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT)
|
||||
.with('s3', async () => putFileInS3(file))
|
||||
.otherwise(async () => putFileInDatabase(file));
|
||||
|
||||
return await createDocumentData({ type, data });
|
||||
};
|
||||
|
||||
const putFileInDatabase = async (file: File) => {
|
||||
const contents = await file.arrayBuffer();
|
||||
|
||||
const binaryData = new Uint8Array(contents);
|
||||
|
||||
const asciiData = base64.encode(binaryData);
|
||||
|
||||
return {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: asciiData,
|
||||
};
|
||||
};
|
||||
|
||||
const putFileInS3 = async (file: File) => {
|
||||
const { url, key } = await getPresignPostUrl(file.name, file.type);
|
||||
|
||||
const body = await file.arrayBuffer();
|
||||
|
||||
const reponse = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!reponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to upload file "${file.name}", failed with status code ${reponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: DocumentDataType.S3_PATH,
|
||||
data: key,
|
||||
};
|
||||
};
|
||||
104
packages/lib/universal/upload/server-actions.ts
Normal file
104
packages/lib/universal/upload/server-actions.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
|
||||
import { getServerComponentSession } from '../../next-auth/get-server-session';
|
||||
import { alphaid } from '../id';
|
||||
|
||||
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
|
||||
const client = getS3Client();
|
||||
|
||||
const user = await getServerComponentSession();
|
||||
|
||||
// Get the basename and extension for the file
|
||||
const { name, ext } = path.parse(fileName);
|
||||
|
||||
let key = `${alphaid(12)}/${slugify(name)}${ext}`;
|
||||
|
||||
if (user) {
|
||||
key = `${user.id}/${key}`;
|
||||
}
|
||||
|
||||
const putObjectCommand = new PutObjectCommand({
|
||||
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(client, putObjectCommand, {
|
||||
expiresIn: ONE_HOUR / ONE_SECOND,
|
||||
});
|
||||
|
||||
return { key, url };
|
||||
};
|
||||
|
||||
export const getAbsolutePresignPostUrl = async (key: string) => {
|
||||
const client = getS3Client();
|
||||
|
||||
const putObjectCommand = new PutObjectCommand({
|
||||
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(client, putObjectCommand, {
|
||||
expiresIn: ONE_HOUR / ONE_SECOND,
|
||||
});
|
||||
|
||||
return { key, url };
|
||||
};
|
||||
|
||||
export const getPresignGetUrl = async (key: string) => {
|
||||
const client = getS3Client();
|
||||
|
||||
const getObjectCommand = new GetObjectCommand({
|
||||
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(client, getObjectCommand, {
|
||||
expiresIn: ONE_HOUR / ONE_SECOND,
|
||||
});
|
||||
|
||||
return { key, url };
|
||||
};
|
||||
|
||||
export const deleteS3File = async (key: string) => {
|
||||
const client = getS3Client();
|
||||
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const getS3Client = () => {
|
||||
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
||||
throw new Error('Invalid upload transport');
|
||||
}
|
||||
|
||||
const hasCredentials =
|
||||
process.env.NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID &&
|
||||
process.env.NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY;
|
||||
|
||||
return new S3Client({
|
||||
endpoint: process.env.NEXT_PRIVATE_UPLOAD_ENDPOINT || undefined,
|
||||
region: process.env.NEXT_PRIVATE_UPLOAD_REGION || 'us-east-1',
|
||||
credentials: hasCredentials
|
||||
? {
|
||||
accessKeyId: String(process.env.NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID),
|
||||
secretAccessKey: String(process.env.NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
58
packages/lib/universal/upload/update-file.ts
Normal file
58
packages/lib/universal/upload/update-file.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { base64 } from '@scure/base';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentDataType } from '@documenso/prisma/client';
|
||||
|
||||
import { getAbsolutePresignPostUrl } from './server-actions';
|
||||
|
||||
export type UpdateFileOptions = {
|
||||
type: DocumentDataType;
|
||||
oldData: string;
|
||||
newData: string;
|
||||
};
|
||||
|
||||
export const updateFile = async ({ type, oldData, newData }: UpdateFileOptions) => {
|
||||
return await match(type)
|
||||
.with(DocumentDataType.BYTES, () => updateFileWithBytes(newData))
|
||||
.with(DocumentDataType.BYTES_64, () => updateFileWithBytes64(newData))
|
||||
.with(DocumentDataType.S3_PATH, async () => updateFileWithS3(oldData, newData))
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const updateFileWithBytes = (data: string) => {
|
||||
return {
|
||||
type: DocumentDataType.BYTES,
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
const updateFileWithBytes64 = (data: string) => {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const binaryData = encoder.encode(data);
|
||||
|
||||
const asciiData = base64.encode(binaryData);
|
||||
|
||||
return {
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: asciiData,
|
||||
};
|
||||
};
|
||||
|
||||
const updateFileWithS3 = async (key: string, data: string) => {
|
||||
const { url } = await getAbsolutePresignPostUrl(key);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update file "${key}", failed with status code ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: DocumentDataType.S3_PATH,
|
||||
data: key,
|
||||
};
|
||||
};
|
||||
52
packages/prisma/helper.ts
Normal file
52
packages/prisma/helper.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/// <reference types="@documenso/tsconfig/process-env.d.ts" />
|
||||
|
||||
export const getDatabaseUrl = () => {
|
||||
if (process.env.NEXT_PRIVATE_DATABASE_URL) {
|
||||
return process.env.NEXT_PRIVATE_DATABASE_URL;
|
||||
}
|
||||
|
||||
if (process.env.POSTGRES_URL) {
|
||||
process.env.NEXT_PRIVATE_DATABASE_URL = process.env.POSTGRES_URL;
|
||||
process.env.NEXT_PRIVATE_DIRECT_DATABASE_URL = process.env.POSTGRES_URL;
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_URL) {
|
||||
process.env.NEXT_PRIVATE_DATABASE_URL = process.env.DATABASE_URL;
|
||||
process.env.NEXT_PRIVATE_DIRECT_DATABASE_URL = process.env.DATABASE_URL;
|
||||
}
|
||||
|
||||
if (process.env.POSTGRES_PRISMA_URL) {
|
||||
process.env.NEXT_PRIVATE_DATABASE_URL = process.env.POSTGRES_PRISMA_URL;
|
||||
}
|
||||
|
||||
if (process.env.POSTGRES_URL_NON_POOLING) {
|
||||
process.env.NEXT_PRIVATE_DIRECT_DATABASE_URL = process.env.POSTGRES_URL_NON_POOLING;
|
||||
}
|
||||
|
||||
// We change the protocol from `postgres:` to `https:` so we can construct a easily
|
||||
// mofifiable URL.
|
||||
const url = new URL(process.env.NEXT_PRIVATE_DATABASE_URL.replace('postgres://', 'https://'));
|
||||
|
||||
// If we're using a connection pool, we need to let Prisma know that
|
||||
// we're using PgBouncer.
|
||||
if (process.env.NEXT_PRIVATE_DATABASE_URL !== process.env.NEXT_PRIVATE_DIRECT_DATABASE_URL) {
|
||||
url.searchParams.set('pgbouncer', 'true');
|
||||
|
||||
process.env.NEXT_PRIVATE_DATABASE_URL = url.toString().replace('https://', 'postgres://');
|
||||
}
|
||||
|
||||
// Support for neon.tech (Neon Database)
|
||||
if (url.hostname.endsWith('neon.tech')) {
|
||||
const [projectId, ...rest] = url.hostname.split('.');
|
||||
|
||||
if (!projectId.endsWith('-pooler')) {
|
||||
url.hostname = `${projectId}-pooler.${rest.join('.')}`;
|
||||
}
|
||||
|
||||
url.searchParams.set('pgbouncer', 'true');
|
||||
|
||||
process.env.NEXT_PRIVATE_DATABASE_URL = url.toString().replace('https://', 'postgres://');
|
||||
}
|
||||
|
||||
return process.env.NEXT_PRIVATE_DATABASE_URL;
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { getDatabaseUrl } from './helper';
|
||||
|
||||
declare global {
|
||||
// We need `var` to declare a global variable in TypeScript
|
||||
// eslint-disable-next-line no-var
|
||||
@@ -7,9 +9,13 @@ declare global {
|
||||
}
|
||||
|
||||
if (!globalThis.prisma) {
|
||||
globalThis.prisma = new PrismaClient();
|
||||
globalThis.prisma = new PrismaClient({ datasourceUrl: getDatabaseUrl() });
|
||||
}
|
||||
|
||||
export const prisma = globalThis.prisma || new PrismaClient();
|
||||
export const prisma =
|
||||
globalThis.prisma ||
|
||||
new PrismaClient({
|
||||
datasourceUrl: getDatabaseUrl(),
|
||||
});
|
||||
|
||||
export const getPrismaClient = () => prisma;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DocumentDataType" AS ENUM ('S3_PATH', 'BYTES', 'BYTES_64');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DocumentData" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "DocumentDataType" NOT NULL,
|
||||
"data" TEXT NOT NULL,
|
||||
"initialData" TEXT NOT NULL,
|
||||
"documentId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "DocumentData_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DocumentData_documentId_key" ON "DocumentData"("documentId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentData" ADD CONSTRAINT "DocumentData_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,14 @@
|
||||
INSERT INTO
|
||||
"DocumentData" ("id", "type", "data", "initialData", "documentId") (
|
||||
SELECT
|
||||
CAST(gen_random_uuid() AS TEXT),
|
||||
'BYTES_64',
|
||||
d."document",
|
||||
d."document",
|
||||
d."id"
|
||||
FROM
|
||||
"Document" d
|
||||
WHERE
|
||||
d."id" IS NOT NULL
|
||||
AND d."document" IS NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" ADD COLUMN "createdAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" ADD COLUMN "updatedAt" TIMESTAMP(3);
|
||||
|
||||
-- DefaultValues
|
||||
UPDATE "Document"
|
||||
SET
|
||||
"createdAt" = COALESCE("created"::TIMESTAMP, NOW()),
|
||||
"updatedAt" = COALESCE("created"::TIMESTAMP, NOW());
|
||||
|
||||
-- AlterColumn
|
||||
ALTER TABLE "Document" ALTER COLUMN "createdAt" SET DEFAULT NOW();
|
||||
ALTER TABLE "Document" ALTER COLUMN "createdAt" SET NOT NULL;
|
||||
|
||||
-- AlterColumn
|
||||
ALTER TABLE "Document" ALTER COLUMN "updatedAt" SET DEFAULT NOW();
|
||||
ALTER TABLE "Document" ALTER COLUMN "updatedAt" SET NOT NULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `document` on the `Document` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" DROP COLUMN "document";
|
||||
@@ -0,0 +1,23 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "DocumentData" DROP CONSTRAINT "DocumentData_documentId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "DocumentData_documentId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" ADD COLUMN "documentDataId" TEXT;
|
||||
|
||||
-- Reverse relation foreign key ids
|
||||
UPDATE "Document" SET "documentDataId" = "DocumentData"."id" FROM "DocumentData" WHERE "Document"."id" = "DocumentData"."documentId";
|
||||
|
||||
-- AlterColumn
|
||||
ALTER TABLE "Document" ALTER COLUMN "documentDataId" SET NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentData" DROP COLUMN "documentId";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Document_documentDataId_key" ON "Document"("documentDataId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_documentDataId_fkey" FOREIGN KEY ("documentDataId") REFERENCES "DocumentData"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `created` on the `Document` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" DROP COLUMN "created";
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PasswordResetToken" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiry" TIMESTAMP(3) NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -9,10 +9,18 @@
|
||||
"format": "prisma format",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate-dev": "prisma migrate dev",
|
||||
"prisma:migrate-deploy": "prisma migrate deploy"
|
||||
"prisma:migrate-deploy": "prisma migrate deploy",
|
||||
"prisma:seed": "prisma db seed"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node --transpileOnly --skipProject ./seed-database.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.0.0",
|
||||
"prisma": "5.0.0"
|
||||
"@prisma/client": "5.3.1",
|
||||
"prisma": "5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user