mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Merge branch 'feat/refresh' into feat/mania
This commit is contained in:
@ -1,20 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "Documenso",
|
"name": "Documenso",
|
||||||
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
|
"image": "mcr.microsoft.com/devcontainers/base:bullseye",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||||
"version": "latest",
|
"version": "latest",
|
||||||
"enableNonRootDocker": "true",
|
"enableNonRootDocker": "true",
|
||||||
"moby": "true"
|
"moby": "true"
|
||||||
},
|
},
|
||||||
"ghcr.io/devcontainers/features/node:1": {}
|
"ghcr.io/devcontainers/features/node:1": {}
|
||||||
},
|
},
|
||||||
"onCreateCommand": "./.devcontainer/on-create.sh",
|
"onCreateCommand": "./.devcontainer/on-create.sh",
|
||||||
"forwardPorts": [
|
"forwardPorts": [3000, 54320, 9000, 2500, 1100],
|
||||||
3000,
|
"customizations": {
|
||||||
54320,
|
"vscode": {
|
||||||
9000,
|
"extensions": [
|
||||||
2500,
|
"aaron-bond.better-comments",
|
||||||
1100
|
"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",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,8 @@ NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
|||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
# [[APP]]
|
# [[APP]]
|
||||||
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
||||||
|
|
||||||
# [[DATABASE]]
|
# [[DATABASE]]
|
||||||
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||||
|
|||||||
@ -5,3 +5,4 @@
|
|||||||
# Statically hosted javascript files
|
# Statically hosted javascript files
|
||||||
apps/*/public/*.js
|
apps/*/public/*.js
|
||||||
apps/*/public/*.cjs
|
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
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Copy env
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|||||||
7
.github/workflows/codeql-analysis.yml
vendored
7
.github/workflows/codeql-analysis.yml
vendored
@ -32,7 +32,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Copy env
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Build Documenso
|
- name: Build Documenso
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
@ -42,4 +45,4 @@ jobs:
|
|||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v2
|
uses: github/codeql-action/analyze@v2
|
||||||
|
|||||||
@ -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)!
|
||||||
@ -18,6 +18,40 @@ const config = {
|
|||||||
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
|
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);
|
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 {
|
declare namespace NodeJS {
|
||||||
export interface ProcessEnv {
|
export interface ProcessEnv {
|
||||||
NEXT_PUBLIC_SITE_URL?: string;
|
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||||
|
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_DATABASE_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 |
@ -161,7 +161,7 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`${process.env.NEXT_PUBLIC_APP_URL}/login`}
|
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="mt-4 block"
|
className="mt-4 block"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -21,12 +21,12 @@ export const metadata = {
|
|||||||
description:
|
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.',
|
'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',
|
type: 'website',
|
||||||
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
|
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
site: '@documenso',
|
site: '@documenso',
|
||||||
card: 'summary_large_image',
|
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:
|
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.',
|
'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.',
|
||||||
},
|
},
|
||||||
|
|||||||
65
apps/marketing/src/app/not-found.tsx
Normal file
65
apps/marketing/src/app/not-found.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import backgroundPattern from '~/assets/background-pattern.png';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
|
||||||
|
<div className="absolute -inset-24 -z-10">
|
||||||
|
<motion.div
|
||||||
|
className="flex h-full w-full items-center justify-center"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={backgroundPattern}
|
||||||
|
alt="background pattern"
|
||||||
|
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%]"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container mx-auto flex h-full min-h-screen items-center px-6 py-32">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground font-semibold">404 Page not found</p>
|
||||||
|
|
||||||
|
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4 text-sm">
|
||||||
|
The page you are looking for was moved, removed, renamed or might never have existed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-32"
|
||||||
|
onClick={() => {
|
||||||
|
void router.back();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button className="w-32" asChild>
|
||||||
|
<Link href="/">Home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,11 +4,11 @@ import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
|
|||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
rules: {
|
rules: [
|
||||||
userAgent: '*',
|
{
|
||||||
allow: '/*',
|
userAgent: '*',
|
||||||
disallow: ['/_next/*'],
|
},
|
||||||
},
|
],
|
||||||
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
sitemap: `${getBaseUrl()}/sitemap.xml`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export default async function handler(
|
|||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription.length > 0) {
|
||||||
return res.status(200).json({
|
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',
|
mode: 'subscription',
|
||||||
metadata,
|
metadata,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/pricing?email=${encodeURIComponent(
|
||||||
email,
|
email,
|
||||||
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
|
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
|
||||||
});
|
});
|
||||||
|
|||||||
3
apps/web/process-env.d.ts
vendored
3
apps/web/process-env.d.ts
vendored
@ -1,6 +1,7 @@
|
|||||||
declare namespace NodeJS {
|
declare namespace NodeJS {
|
||||||
export interface ProcessEnv {
|
export interface ProcessEnv {
|
||||||
NEXT_PUBLIC_SITE_URL?: string;
|
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||||
|
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_DATABASE_URL: string;
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
|
|
||||||
|
|||||||
50
apps/web/src/app/(dashboard)/documents/empty-state.tsx
Normal file
50
apps/web/src/app/(dashboard)/documents/empty-state.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Bird, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
|
export type EmptyDocumentProps = { status: ExtendedDocumentStatus };
|
||||||
|
|
||||||
|
export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
icon: Icon,
|
||||||
|
} = match(status)
|
||||||
|
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||||
|
title: 'Nothing to do',
|
||||||
|
message:
|
||||||
|
'There are no completed documents yet. Documents that you have created or received that become completed will appear here later.',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
}))
|
||||||
|
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||||
|
title: 'No active drafts',
|
||||||
|
message:
|
||||||
|
'There are no active drafts at then current moment. You can upload a document to start drafting.',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
}))
|
||||||
|
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||||
|
title: "We're all empty",
|
||||||
|
message:
|
||||||
|
'You have not yet created or received any documents. To create a document please upload one.',
|
||||||
|
icon: Bird,
|
||||||
|
}))
|
||||||
|
.otherwise(() => ({
|
||||||
|
title: 'Nothing to do',
|
||||||
|
message:
|
||||||
|
'All documents are currently actioned. Any new documents are sent or recieved they will start to appear here.',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
||||||
|
<Icon className="h-12 w-12" strokeWidth={1.5} />
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
|
||||||
|
<p className="mt-2 max-w-[60ch]">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -12,6 +12,7 @@ import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/ty
|
|||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
import { DocumentsDataTable } from './data-table';
|
import { DocumentsDataTable } from './data-table';
|
||||||
|
import { EmptyDocumentState } from './empty-state';
|
||||||
import { UploadDocument } from './upload-document';
|
import { UploadDocument } from './upload-document';
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
@ -96,7 +97,8 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<DocumentsDataTable results={results} />
|
{results.count > 0 && <DocumentsDataTable results={results} />}
|
||||||
|
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -68,7 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export default async function BillingSettingsPage() {
|
|||||||
if (subscription.customerId) {
|
if (subscription.customerId) {
|
||||||
billingPortalUrl = await getPortalSession({
|
billingPortalUrl = await getPortalSession({
|
||||||
customerId: subscription.customerId,
|
customerId: subscription.customerId,
|
||||||
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
|
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,7 +43,7 @@ export default async function BillingSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Billing</h3>
|
<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{' '}
|
Your subscription is{' '}
|
||||||
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
|
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
|
||||||
{subscription?.periodEnd && (
|
{subscription?.periodEnd && (
|
||||||
@ -67,7 +67,7 @@ export default async function BillingSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!billingPortalUrl && (
|
{!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
|
You do not currently have a customer record, this should not happen. Please contact
|
||||||
support for assistance.
|
support for assistance.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export default async function PasswordSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Password</h3>
|
<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" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export default async function ProfileSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Profile</h3>
|
<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" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
|||||||
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 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';
|
import { SignInForm } from '~/components/forms/signin';
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
return (
|
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>
|
||||||
<div className="relative flex max-w-4xl items-center gap-x-24">
|
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||||
<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="max-w-md">
|
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
Welcome back, we are lucky to have you.
|
||||||
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground/60 mt-2 text-sm">
|
<SignInForm className="mt-4" />
|
||||||
Welcome back, we are lucky to have you.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<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">
|
<p className="mt-2.5 text-center">
|
||||||
Don't have an account?{' '}
|
<Link
|
||||||
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
|
href="/forgot-password"
|
||||||
Sign up
|
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
||||||
</Link>
|
>
|
||||||
</p>
|
Forgotten your password?
|
||||||
</div>
|
</Link>
|
||||||
|
</p>
|
||||||
<div className="hidden flex-1 lg:block">
|
</div>
|
||||||
<Image src={connections} alt="documenso connections" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,44 +1,25 @@
|
|||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import backgroundPattern from '~/assets/background-pattern.png';
|
|
||||||
import connections from '~/assets/connections.png';
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
return (
|
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>
|
||||||
<div className="relative flex max-w-4xl items-center gap-x-24">
|
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||||
<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="max-w-md">
|
<p className="text-muted-foreground/60 mt-2 text-sm">
|
||||||
<h1 className="text-4xl font-semibold">Create a shiny, new Documenso Account ✨</h1>
|
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">
|
<SignUpForm className="mt-4" />
|
||||||
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" />
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
Already have an account?{' '}
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
||||||
Already have an account?{' '}
|
Sign in instead
|
||||||
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
|
</Link>
|
||||||
Sign in instead
|
</p>
|
||||||
</Link>
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden flex-1 lg:block">
|
|
||||||
<Image src={connections} alt="documenso connections" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,12 +33,12 @@ export const metadata = {
|
|||||||
description:
|
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.',
|
'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',
|
type: 'website',
|
||||||
images: [`${process.env.NEXT_PUBLIC_SITE_URL}/opengraph-image.jpg`],
|
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
site: '@documenso',
|
site: '@documenso',
|
||||||
card: 'summary_large_image',
|
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:
|
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.',
|
'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.',
|
||||||
},
|
},
|
||||||
|
|||||||
26
apps/web/src/app/not-found.tsx
Normal file
26
apps/web/src/app/not-found.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import NotFoundPartial from '~/components/partials/not-found';
|
||||||
|
|
||||||
|
export default async function NotFound() {
|
||||||
|
const session = await getServerComponentSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotFoundPartial>
|
||||||
|
{session && (
|
||||||
|
<Button className="w-32" asChild>
|
||||||
|
<Link href="/documents">Documents</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!session && (
|
||||||
|
<Button className="w-32" asChild>
|
||||||
|
<Link href="/signin">Sign In</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</NotFoundPartial>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import {
|
|||||||
User as LucideUser,
|
User as LucideUser,
|
||||||
Monitor,
|
Monitor,
|
||||||
Moon,
|
Moon,
|
||||||
|
Palette,
|
||||||
Sun,
|
Sun,
|
||||||
UserCog,
|
UserCog,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@ -26,7 +27,13 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
@ -37,8 +44,8 @@ export type ProfileDropdownProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
const { getFlag } = useFeatureFlags();
|
const { getFlag } = useFeatureFlags();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
const isUserAdmin = isAdmin(user);
|
const isUserAdmin = isAdmin(user);
|
||||||
|
|
||||||
const isBillingEnabled = getFlag('app_billing');
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
@ -98,28 +105,30 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
{theme === 'light' ? null : (
|
<DropdownMenuSub>
|
||||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
<DropdownMenuSubTrigger>
|
||||||
<Sun className="mr-2 h-4 w-4" />
|
<Palette className="mr-2 h-4 w-4" />
|
||||||
Light Mode
|
Themes
|
||||||
</DropdownMenuItem>
|
</DropdownMenuSubTrigger>
|
||||||
)}
|
<DropdownMenuPortal>
|
||||||
{theme === 'dark' ? null : (
|
<DropdownMenuSubContent>
|
||||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
|
||||||
<Moon className="mr-2 h-4 w-4" />
|
<DropdownMenuRadioItem value="light">
|
||||||
Dark Mode
|
<Sun className="mr-2 h-4 w-4" /> Light
|
||||||
</DropdownMenuItem>
|
</DropdownMenuRadioItem>
|
||||||
)}
|
<DropdownMenuRadioItem value="dark">
|
||||||
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
{theme === 'system' ? null : (
|
Dark
|
||||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
</DropdownMenuRadioItem>
|
||||||
<Monitor className="mr-2 h-4 w-4" />
|
<DropdownMenuRadioItem value="system">
|
||||||
System Theme
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
System
|
||||||
)}
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
|
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
|
||||||
<Github className="mr-2 h-4 w-4" />
|
<Github className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@ -44,7 +44,7 @@ export const PeriodSelector = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Select defaultValue={period} onValueChange={onPeriodChange}>
|
<Select defaultValue={period} onValueChange={onPeriodChange}>
|
||||||
<SelectTrigger className="max-w-[200px] text-slate-500">
|
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
|
|
||||||
@ -8,12 +9,20 @@ export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
|||||||
documentId: number;
|
documentId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const completeDocument = async ({ documentId }: CompleteDocumentActionInput) => {
|
export const completeDocument = async ({ documentId, email }: CompleteDocumentActionInput) => {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
const { id: userId } = await getRequiredServerComponentSession();
|
const { id: userId } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
await sendDocument({
|
if (email.message || email.subject) {
|
||||||
|
await upsertDocumentMeta({
|
||||||
|
documentId,
|
||||||
|
subject: email.subject,
|
||||||
|
message: email.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sendDocument({
|
||||||
userId,
|
userId,
|
||||||
documentId,
|
documentId,
|
||||||
});
|
});
|
||||||
|
|||||||
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';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
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 { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@ -36,6 +38,9 @@ export type PasswordFormProps = {
|
|||||||
export const PasswordForm = ({ className }: PasswordFormProps) => {
|
export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@ -88,37 +93,69 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="password" className="text-slate-500">
|
<Label htmlFor="password" className="text-muted-foreground">
|
||||||
Password
|
Password
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<div className="relative">
|
||||||
id="password"
|
<Input
|
||||||
type="password"
|
id="password"
|
||||||
minLength={6}
|
type={showPassword ? 'text' : 'password'}
|
||||||
maxLength={72}
|
minLength={6}
|
||||||
autoComplete="new-password"
|
maxLength={72}
|
||||||
className="bg-background mt-2"
|
autoComplete="new-password"
|
||||||
{...register('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} />
|
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="repeated-password" className="text-slate-500">
|
<Label htmlFor="repeated-password" className="text-muted-foreground">
|
||||||
Repeat Password
|
Repeat Password
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<div className="relative">
|
||||||
id="repeated-password"
|
<Input
|
||||||
type="password"
|
id="repeated-password"
|
||||||
minLength={6}
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
maxLength={72}
|
minLength={6}
|
||||||
autoComplete="new-password"
|
maxLength={72}
|
||||||
className="bg-background mt-2"
|
autoComplete="new-password"
|
||||||
{...register('repeatedPassword')}
|
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} />
|
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -89,7 +89,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="full-name" className="text-slate-500">
|
<Label htmlFor="full-name" className="text-muted-foreground">
|
||||||
Full Name
|
Full Name
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
@ -99,7 +99,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="email" className="text-slate-500">
|
<Label htmlFor="email" className="text-muted-foreground">
|
||||||
Email
|
Email
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
@ -107,7 +107,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="signature" className="text-slate-500">
|
<Label htmlFor="signature" className="text-muted-foreground">
|
||||||
Signature
|
Signature
|
||||||
</Label>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
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 { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
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 { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
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 { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
@ -42,6 +43,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@ -113,33 +115,47 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="email" className="text-slate-500">
|
<Label htmlFor="email" className="text-muted-forground">
|
||||||
Email
|
Email
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="password" className="text-slate-500">
|
<Label htmlFor="password" className="text-muted-forground">
|
||||||
Password
|
<span>Password</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<div className="relative">
|
||||||
id="password"
|
<Input
|
||||||
type="password"
|
id="password"
|
||||||
minLength={6}
|
type={showPassword ? 'text' : 'password'}
|
||||||
maxLength={72}
|
minLength={6}
|
||||||
autoComplete="current-password"
|
maxLength={72}
|
||||||
className="bg-background mt-2"
|
autoComplete="current-password"
|
||||||
{...register('password')}
|
className="bg-background mt-2 pr-10"
|
||||||
/>
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
|
||||||
{errors.password && (
|
<Button
|
||||||
<span className="mt-1 text-xs text-red-500">{errors.password.message}</span>
|
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>
|
||||||
|
|
||||||
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
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 { signIn } from 'next-auth/react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -31,6 +33,7 @@ export type SignUpFormProps = {
|
|||||||
|
|
||||||
export const SignUpForm = ({ className }: SignUpFormProps) => {
|
export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@ -106,15 +109,31 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
Password
|
Password
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<div className="relative">
|
||||||
id="password"
|
<Input
|
||||||
type="password"
|
id="password"
|
||||||
minLength={6}
|
type={showPassword ? 'text' : 'password'}
|
||||||
maxLength={72}
|
minLength={6}
|
||||||
autoComplete="new-password"
|
maxLength={72}
|
||||||
className="bg-background mt-2"
|
autoComplete="new-password"
|
||||||
{...register('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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
export * from 'framer-motion';
|
|
||||||
|
|
||||||
export const MotionDiv = motion.div;
|
|
||||||
66
apps/web/src/components/partials/not-found.tsx
Normal file
66
apps/web/src/components/partials/not-found.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import backgroundPattern from '~/assets/background-pattern.png';
|
||||||
|
|
||||||
|
export type NotFoundPartialProps = {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotFoundPartial({ children }: NotFoundPartialProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
|
||||||
|
<div className="absolute -inset-24 -z-10">
|
||||||
|
<motion.div
|
||||||
|
className="flex h-full w-full items-center justify-center"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={backgroundPattern}
|
||||||
|
alt="background pattern"
|
||||||
|
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%]"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container mx-auto flex h-full min-h-screen items-center px-6 py-32">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground font-semibold">404 Page not found</p>
|
||||||
|
|
||||||
|
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4 text-sm">
|
||||||
|
The page you are looking for was moved, removed, renamed or might never have existed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-32"
|
||||||
|
onClick={() => {
|
||||||
|
void router.back();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -21,7 +21,7 @@ export const getFlag = async (
|
|||||||
return LOCAL_FEATURE_FLAGS[flag] ?? true;
|
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);
|
url.searchParams.set('flag', flag);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@ -54,7 +54,7 @@ export const getAllFlags = async (
|
|||||||
return LOCAL_FEATURE_FLAGS;
|
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, {
|
return fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export default async function handler(
|
|||||||
|
|
||||||
if (user && user.Subscription.length > 0) {
|
if (user && user.Subscription.length > 0) {
|
||||||
return res.status(200).json({
|
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',
|
mode: 'subscription',
|
||||||
metadata,
|
metadata,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?email=${encodeURIComponent(
|
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/pricing?email=${encodeURIComponent(
|
||||||
email,
|
email,
|
||||||
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
|
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
|
||||||
});
|
});
|
||||||
|
|||||||
BIN
assets/example.pdf
Normal file
BIN
assets/example.pdf
Normal file
Binary file not shown.
1390
package-lock.json
generated
1390
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -17,11 +17,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "^0.0.7",
|
"@react-email/components": "^0.0.7",
|
||||||
"nodemailer": "^6.9.3"
|
"nodemailer": "^6.9.3",
|
||||||
|
"react-email": "^1.9.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tsconfig": "*",
|
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
|
"@documenso/tsconfig": "*",
|
||||||
"@types/nodemailer": "^6.4.8",
|
"@types/nodemailer": "^6.4.8",
|
||||||
"tsup": "^7.1.0"
|
"tsup": "^7.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
|
import { Button, Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
import * as config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
@ -29,11 +29,23 @@ export const TemplateDocumentCompleted = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Section className="flex-row items-center justify-center">
|
<Section>
|
||||||
<div className="flex items-center justify-center p-4">
|
<Row className="table-fixed">
|
||||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
<Column />
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<Column>
|
||||||
|
<Img
|
||||||
|
className="h-42 mx-auto"
|
||||||
|
src={getAssetUrl('/static/document.png')}
|
||||||
|
alt="Documenso"
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column />
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
|
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
|
||||||
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
||||||
Completed
|
Completed
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
|
import { Button, Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
import * as config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
@ -30,13 +30,26 @@ export const TemplateDocumentInvite = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Section className="mt-4 flex-row items-center justify-center">
|
<Section className="mt-4">
|
||||||
<div className="flex items-center justify-center p-4">
|
<Row className="table-fixed">
|
||||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
<Column />
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<Column>
|
||||||
|
<Img
|
||||||
|
className="h-42 mx-auto"
|
||||||
|
src={getAssetUrl('/static/document.png')}
|
||||||
|
alt="Documenso"
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column />
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||||
{inviterName} has invited you to sign "{documentName}"
|
{inviterName} has invited you to sign
|
||||||
|
<br />"{documentName}"
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="my-1 text-center text-base text-slate-400">
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Img, Section, Tailwind, Text } from '@react-email/components';
|
import { Column, Img, Row, Section, Tailwind, Text } from '@react-email/components';
|
||||||
|
|
||||||
import * as config from '@documenso/tailwind-config';
|
import * as config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
@ -25,11 +25,23 @@ export const TemplateDocumentPending = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Section className="flex-row items-center justify-center">
|
<Section>
|
||||||
<div className="flex items-center justify-center p-4">
|
<Row className="table-fixed">
|
||||||
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
|
<Column />
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<Column>
|
||||||
|
<Img
|
||||||
|
className="h-42 mx-auto"
|
||||||
|
src={getAssetUrl('/static/document.png')}
|
||||||
|
alt="Documenso"
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column />
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
|
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
|
||||||
<Img src={getAssetUrl('/static/clock.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
<Img src={getAssetUrl('/static/clock.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
|
||||||
Waiting for others
|
Waiting for others
|
||||||
|
|||||||
@ -1,14 +1,20 @@
|
|||||||
import { Link, Section, Text } from '@react-email/components';
|
import { Link, Section, Text } from '@react-email/components';
|
||||||
|
|
||||||
export const TemplateFooter = () => {
|
export type TemplateFooterProps = {
|
||||||
|
isDocument?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||||
return (
|
return (
|
||||||
<Section>
|
<Section>
|
||||||
<Text className="my-4 text-base text-slate-400">
|
{isDocument && (
|
||||||
This document was sent using{' '}
|
<Text className="my-4 text-base text-slate-400">
|
||||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
This document was sent using{' '}
|
||||||
Documenso.
|
<Link className="text-[#7AC455]" href="https://documenso.com">
|
||||||
</Link>
|
Documenso.
|
||||||
</Text>
|
</Link>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text className="my-8 text-sm text-slate-400">
|
<Text className="my-8 text-sm text-slate-400">
|
||||||
Documenso
|
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;
|
||||||
@ -20,7 +20,9 @@ import {
|
|||||||
} from '../template-components/template-document-invite';
|
} from '../template-components/template-document-invite';
|
||||||
import TemplateFooter from '../template-components/template-footer';
|
import TemplateFooter from '../template-components/template-footer';
|
||||||
|
|
||||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps>;
|
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||||
|
customBody?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const DocumentInviteEmailTemplate = ({
|
export const DocumentInviteEmailTemplate = ({
|
||||||
inviterName = 'Lucas Smith',
|
inviterName = 'Lucas Smith',
|
||||||
@ -28,6 +30,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
documentName = 'Open Source Pledge.pdf',
|
documentName = 'Open Source Pledge.pdf',
|
||||||
signDocumentLink = 'https://documenso.com',
|
signDocumentLink = 'https://documenso.com',
|
||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
|
customBody,
|
||||||
}: DocumentInviteEmailTemplateProps) => {
|
}: DocumentInviteEmailTemplateProps) => {
|
||||||
const previewText = `Completed Document`;
|
const previewText = `Completed Document`;
|
||||||
|
|
||||||
@ -78,7 +81,11 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="mt-2 text-base text-slate-400">
|
<Text className="mt-2 text-base text-slate-400">
|
||||||
{inviterName} has invited you to sign the document "{documentName}".
|
{customBody ? (
|
||||||
|
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
|
||||||
|
) : (
|
||||||
|
`${inviterName} has invited you to sign the document "${documentName}".`
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
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;
|
||||||
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,30 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type CreateDocumentMetaOptions = {
|
||||||
|
documentId: number;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upsertDocumentMeta = async ({
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
documentId,
|
||||||
|
}: CreateDocumentMetaOptions) => {
|
||||||
|
return await prisma.documentMeta.upsert({
|
||||||
|
where: {
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
documentId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -4,6 +4,7 @@ import { prisma } from '@documenso/prisma';
|
|||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { sealDocument } from './seal-document';
|
import { sealDocument } from './seal-document';
|
||||||
|
import { sendPendingEmail } from './send-pending-email';
|
||||||
|
|
||||||
export type CompleteDocumentWithTokenOptions = {
|
export type CompleteDocumentWithTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
@ -69,6 +70,19 @@ export const completeDocumentWithToken = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pendingRecipients = await prisma.recipient.count({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
signingStatus: {
|
||||||
|
not: SigningStatus.SIGNED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingRecipients > 0) {
|
||||||
|
await sendPendingEmail({ documentId, recipientId: recipient.id });
|
||||||
|
}
|
||||||
|
|
||||||
const documents = await prisma.document.updateMany({
|
const documents = await prisma.document.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id: document.id,
|
id: document.id,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
documentData: true,
|
documentData: true,
|
||||||
|
documentMeta: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
|||||||
import { getFile } from '../../universal/upload/get-file';
|
import { getFile } from '../../universal/upload/get-file';
|
||||||
import { putFile } from '../../universal/upload/put-file';
|
import { putFile } from '../../universal/upload/put-file';
|
||||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||||
|
import { sendCompletedEmail } from './send-completed-email';
|
||||||
|
|
||||||
export type SealDocumentOptions = {
|
export type SealDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@ -86,4 +87,6 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
|||||||
data: newData,
|
data: newData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await sendCompletedEmail({ documentId });
|
||||||
};
|
};
|
||||||
|
|||||||
57
packages/lib/server-only/document/send-completed-email.ts
Normal file
57
packages/lib/server-only/document/send-completed-email.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface SendDocumentOptions {
|
||||||
|
documentId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) => {
|
||||||
|
const document = await prisma.document.findUnique({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.Recipient.length === 0) {
|
||||||
|
throw new Error('Document has no recipients');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
document.Recipient.map(async (recipient) => {
|
||||||
|
const { email, name, token } = recipient;
|
||||||
|
|
||||||
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
const template = createElement(DocumentCompletedEmailTemplate, {
|
||||||
|
documentName: document.title,
|
||||||
|
assetBaseUrl,
|
||||||
|
downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: email,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||||
|
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||||
|
},
|
||||||
|
subject: 'Signing Complete!',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
@ -3,13 +3,14 @@ import { createElement } from 'react';
|
|||||||
import { mailer } from '@documenso/email/mailer';
|
import { mailer } from '@documenso/email/mailer';
|
||||||
import { render } from '@documenso/email/render';
|
import { render } from '@documenso/email/render';
|
||||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||||
|
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export interface SendDocumentOptions {
|
export type SendDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
|
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
@ -25,9 +26,12 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
Recipient: true,
|
Recipient: true,
|
||||||
|
documentMeta: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const customEmail = document?.documentMeta;
|
||||||
|
|
||||||
if (!document) {
|
if (!document) {
|
||||||
throw new Error('Document not found');
|
throw new Error('Document not found');
|
||||||
}
|
}
|
||||||
@ -44,12 +48,18 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
document.Recipient.map(async (recipient) => {
|
document.Recipient.map(async (recipient) => {
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
|
|
||||||
|
const customEmailTemplate = {
|
||||||
|
'signer.name': name,
|
||||||
|
'signer.email': email,
|
||||||
|
'document.name': document.title,
|
||||||
|
};
|
||||||
|
|
||||||
if (recipient.sendStatus === SendStatus.SENT) {
|
if (recipient.sendStatus === SendStatus.SENT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetBaseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
const signDocumentLink = `${process.env.NEXT_PUBLIC_SITE_URL}/sign/${recipient.token}`;
|
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
|
||||||
|
|
||||||
const template = createElement(DocumentInviteEmailTemplate, {
|
const template = createElement(DocumentInviteEmailTemplate, {
|
||||||
documentName: document.title,
|
documentName: document.title,
|
||||||
@ -57,6 +67,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
inviterEmail: user.email,
|
inviterEmail: user.email,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
|
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||||
});
|
});
|
||||||
|
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
@ -68,7 +79,9 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||||
},
|
},
|
||||||
subject: 'Please sign this document',
|
subject: customEmail?.subject
|
||||||
|
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||||
|
: 'Please sign this document',
|
||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
64
packages/lib/server-only/document/send-pending-email.ts
Normal file
64
packages/lib/server-only/document/send-pending-email.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface SendPendingEmailOptions {
|
||||||
|
documentId: number;
|
||||||
|
recipientId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingEmailOptions) => {
|
||||||
|
const document = await prisma.document.findFirst({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
id: recipientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: {
|
||||||
|
where: {
|
||||||
|
id: recipientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.Recipient.length === 0) {
|
||||||
|
throw new Error('Document has no recipients');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [recipient] = document.Recipient;
|
||||||
|
|
||||||
|
const { email, name } = recipient;
|
||||||
|
|
||||||
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
const template = createElement(DocumentPendingEmailTemplate, {
|
||||||
|
documentName: document.title,
|
||||||
|
assetBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: email,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||||
|
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||||
|
},
|
||||||
|
subject: 'Waiting for others to complete signing.',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
};
|
||||||
21
packages/lib/server-only/document/update-document.ts
Normal file
21
packages/lib/server-only/document/update-document.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type UpdateDocumentOptions = {
|
||||||
|
documentId: number;
|
||||||
|
data: Prisma.DocumentUpdateInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateDocument = async ({ documentId, data }: UpdateDocumentOptions) => {
|
||||||
|
return await prisma.document.update({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -50,10 +50,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
let imageWidth = image.width;
|
let imageWidth = image.width;
|
||||||
let imageHeight = image.height;
|
let imageHeight = image.height;
|
||||||
|
|
||||||
const initialDimensions = {
|
// const initialDimensions = {
|
||||||
width: imageWidth,
|
// width: imageWidth,
|
||||||
height: imageHeight,
|
// height: imageHeight,
|
||||||
};
|
// };
|
||||||
|
|
||||||
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
||||||
|
|
||||||
@ -76,10 +76,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
||||||
const textHeight = font.heightAtSize(fontSize);
|
const textHeight = font.heightAtSize(fontSize);
|
||||||
|
|
||||||
const initialDimensions = {
|
// const initialDimensions = {
|
||||||
width: textWidth,
|
// width: textWidth,
|
||||||
height: textHeight,
|
// height: textHeight,
|
||||||
};
|
// };
|
||||||
|
|
||||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||||
|
|
||||||
|
|||||||
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 });
|
||||||
|
};
|
||||||
@ -8,8 +8,8 @@ export const getBaseUrl = () => {
|
|||||||
return `https://${process.env.VERCEL_URL}`;
|
return `https://${process.env.VERCEL_URL}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_SITE_URL) {
|
if (process.env.NEXT_PUBLIC_WEBAPP_URL) {
|
||||||
return `https://${process.env.NEXT_PUBLIC_SITE_URL}`;
|
return process.env.NEXT_PUBLIC_WEBAPP_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||||
|
|||||||
12
packages/lib/utils/render-custom-email-template.ts
Normal file
12
packages/lib/utils/render-custom-email-template.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export const renderCustomEmailTemplate = <T extends Record<string, string>>(
|
||||||
|
template: string,
|
||||||
|
variables: T,
|
||||||
|
): string => {
|
||||||
|
return template.replace(/\{(\S+)\}/g, (_, key) => {
|
||||||
|
if (key in variables) {
|
||||||
|
return variables[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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 { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { getDatabaseUrl } from './helper';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// We need `var` to declare a global variable in TypeScript
|
// We need `var` to declare a global variable in TypeScript
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
@ -7,9 +9,13 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!globalThis.prisma) {
|
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;
|
export const getPrismaClient = () => prisma;
|
||||||
|
|||||||
@ -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;
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Document" ADD COLUMN "documentMetaId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DocumentMeta" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"customEmailSubject" TEXT,
|
||||||
|
"customEmailBody" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "DocumentMeta_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Document" ADD CONSTRAINT "Document_documentMetaId_fkey" FOREIGN KEY ("documentMetaId") REFERENCES "DocumentMeta"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[documentMetaId]` on the table `Document` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Document_documentMetaId_key" ON "Document"("documentMetaId");
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `documentMetaId` on the `Document` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `customEmailBody` on the `DocumentMeta` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `customEmailSubject` on the `DocumentMeta` table. All the data in the column will be lost.
|
||||||
|
- A unique constraint covering the columns `[documentId]` on the table `DocumentMeta` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `documentId` to the `DocumentMeta` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Document" DROP CONSTRAINT "Document_documentMetaId_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Document_documentMetaId_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentMeta"
|
||||||
|
ADD COLUMN "documentId" INTEGER,
|
||||||
|
ADD COLUMN "message" TEXT,
|
||||||
|
ADD COLUMN "subject" TEXT;
|
||||||
|
|
||||||
|
-- Migrate data
|
||||||
|
UPDATE "DocumentMeta" SET "documentId" = (
|
||||||
|
SELECT "id" FROM "Document" WHERE "Document"."documentMetaId" = "DocumentMeta"."id"
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Migrate data
|
||||||
|
UPDATE "DocumentMeta" SET "message" = "customEmailBody";
|
||||||
|
|
||||||
|
-- Migrate data
|
||||||
|
UPDATE "DocumentMeta" SET "subject" = "customEmailSubject";
|
||||||
|
|
||||||
|
-- Prune data
|
||||||
|
DELETE FROM "DocumentMeta" WHERE "documentId" IS NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Document" DROP COLUMN "documentMetaId";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentMeta"
|
||||||
|
DROP COLUMN "customEmailBody",
|
||||||
|
DROP COLUMN "customEmailSubject";
|
||||||
|
|
||||||
|
-- AlterColumn
|
||||||
|
ALTER TABLE "DocumentMeta" ALTER COLUMN "documentId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DocumentMeta_documentId_key" ON "DocumentMeta"("documentId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DocumentMeta" ADD CONSTRAINT "DocumentMeta_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -9,10 +9,18 @@
|
|||||||
"format": "prisma format",
|
"format": "prisma format",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate-dev": "prisma migrate dev",
|
"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": {
|
"dependencies": {
|
||||||
"@prisma/client": "5.0.0",
|
"@prisma/client": "5.3.1",
|
||||||
"prisma": "5.0.0"
|
"prisma": "5.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"typescript": "^5.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,19 +19,29 @@ enum Role {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String?
|
name String?
|
||||||
email String @unique
|
email String @unique
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
password String?
|
password String?
|
||||||
source String?
|
source String?
|
||||||
signature String?
|
signature String?
|
||||||
roles Role[] @default([USER])
|
roles Role[] @default([USER])
|
||||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
Document Document[]
|
Document Document[]
|
||||||
Subscription Subscription[]
|
Subscription Subscription[]
|
||||||
|
PasswordResetToken PasswordResetToken[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model PasswordResetToken {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
token String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiry DateTime
|
||||||
|
userId Int
|
||||||
|
User User @relation(fields: [userId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SubscriptionStatus {
|
enum SubscriptionStatus {
|
||||||
@ -100,6 +110,7 @@ model Document {
|
|||||||
Field Field[]
|
Field Field[]
|
||||||
documentDataId String
|
documentDataId String
|
||||||
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
||||||
|
documentMeta DocumentMeta?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
@ -120,6 +131,14 @@ model DocumentData {
|
|||||||
Document Document?
|
Document Document?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DocumentMeta {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
subject String?
|
||||||
|
message String?
|
||||||
|
documentId Int @unique
|
||||||
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
enum ReadStatus {
|
enum ReadStatus {
|
||||||
NOT_OPENED
|
NOT_OPENED
|
||||||
OPENED
|
OPENED
|
||||||
|
|||||||
82
packages/prisma/seed-database.ts
Normal file
82
packages/prisma/seed-database.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { DocumentDataType, Role } from '@prisma/client';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { hashSync } from '@documenso/lib/server-only/auth/hash';
|
||||||
|
|
||||||
|
import { prisma } from './index';
|
||||||
|
|
||||||
|
const seedDatabase = async () => {
|
||||||
|
const examplePdf = fs
|
||||||
|
.readFileSync(path.join(__dirname, '../../assets/example.pdf'))
|
||||||
|
.toString('base64');
|
||||||
|
|
||||||
|
const exampleUser = await prisma.user.upsert({
|
||||||
|
where: {
|
||||||
|
email: 'example@documenso.com',
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: 'Example User',
|
||||||
|
email: 'example@documenso.com',
|
||||||
|
password: hashSync('password'),
|
||||||
|
roles: [Role.USER],
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminUser = await prisma.user.upsert({
|
||||||
|
where: {
|
||||||
|
email: 'admin@documenso.com',
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: 'Admin User',
|
||||||
|
email: 'admin@documenso.com',
|
||||||
|
password: hashSync('password'),
|
||||||
|
roles: [Role.USER, Role.ADMIN],
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const examplePdfData = await prisma.documentData.upsert({
|
||||||
|
where: {
|
||||||
|
id: 'clmn0kv5k0000pe04vcqg5zla',
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: 'clmn0kv5k0000pe04vcqg5zla',
|
||||||
|
type: DocumentDataType.BYTES_64,
|
||||||
|
data: examplePdf,
|
||||||
|
initialData: examplePdf,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.document.upsert({
|
||||||
|
where: {
|
||||||
|
id: 1,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: 1,
|
||||||
|
title: 'Example Document',
|
||||||
|
documentDataId: examplePdfData.id,
|
||||||
|
userId: exampleUser.id,
|
||||||
|
Recipient: {
|
||||||
|
create: {
|
||||||
|
name: String(adminUser.name),
|
||||||
|
email: adminUser.email,
|
||||||
|
token: Math.random().toString(36).slice(2, 9),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
seedDatabase()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Database seeded');
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { Document, DocumentData } from '@documenso/prisma/client';
|
import { Document, DocumentData, DocumentMeta } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type DocumentWithData = Document & {
|
export type DocumentWithData = Document & {
|
||||||
documentData?: DocumentData | null;
|
documentData?: DocumentData | null;
|
||||||
|
documentMeta?: DocumentMeta | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
|
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||||
|
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
||||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||||
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
||||||
|
|
||||||
import { authenticatedProcedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import { ZUpdatePasswordMutationSchema, ZUpdateProfileMutationSchema } from './schema';
|
import {
|
||||||
|
ZForgotPasswordFormSchema,
|
||||||
|
ZResetPasswordFormSchema,
|
||||||
|
ZUpdatePasswordMutationSchema,
|
||||||
|
ZUpdateProfileMutationSchema,
|
||||||
|
} from './schema';
|
||||||
|
|
||||||
export const profileRouter = router({
|
export const profileRouter = router({
|
||||||
updateProfile: authenticatedProcedure
|
updateProfile: authenticatedProcedure
|
||||||
@ -53,4 +60,38 @@ export const profileRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
forgotPassword: procedure.input(ZForgotPasswordFormSchema).mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const { email } = input;
|
||||||
|
|
||||||
|
return await forgotPassword({
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const { password, token } = input;
|
||||||
|
|
||||||
|
return await resetPassword({
|
||||||
|
token,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
let message = 'We were unable to reset your password. Please try again.';
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
message = err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,10 +5,20 @@ export const ZUpdateProfileMutationSchema = z.object({
|
|||||||
signature: z.string(),
|
signature: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
|
||||||
|
|
||||||
export const ZUpdatePasswordMutationSchema = z.object({
|
export const ZUpdatePasswordMutationSchema = z.object({
|
||||||
password: z.string().min(6),
|
password: z.string().min(6),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ZForgotPasswordFormSchema = z.object({
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZResetPasswordFormSchema = z.object({
|
||||||
|
password: z.string().min(6),
|
||||||
|
token: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
||||||
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
||||||
|
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||||
|
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||||
|
|||||||
17
packages/tsconfig/process-env.d.ts
vendored
17
packages/tsconfig/process-env.d.ts
vendored
@ -1,6 +1,7 @@
|
|||||||
declare namespace NodeJS {
|
declare namespace NodeJS {
|
||||||
export interface ProcessEnv {
|
export interface ProcessEnv {
|
||||||
NEXT_PUBLIC_SITE_URL?: string;
|
NEXT_PUBLIC_WEBAPP_URL?: string;
|
||||||
|
NEXT_PUBLIC_MARKETING_URL?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string;
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string;
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
|
||||||
@ -40,5 +41,19 @@ declare namespace NodeJS {
|
|||||||
|
|
||||||
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
|
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
|
||||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
|
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vercel environment variables
|
||||||
|
*/
|
||||||
|
VERCEL?: string;
|
||||||
|
VERCEL_ENV?: 'production' | 'development' | 'preview';
|
||||||
|
VERCEL_URL?: string;
|
||||||
|
|
||||||
|
DEPLOYMENT_TARGET?: 'webapp' | 'marketing';
|
||||||
|
|
||||||
|
POSTGRES_URL?: string;
|
||||||
|
DATABASE_URL?: string;
|
||||||
|
POSTGRES_PRISMA_URL?: string;
|
||||||
|
POSTGRES_URL_NON_POOLING?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { Document, DocumentStatus, Field, Recipient } from '@documenso/prisma/client';
|
import { DocumentStatus, Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
@ -21,7 +22,7 @@ export type AddSubjectFormProps = {
|
|||||||
documentFlow: DocumentFlowStep;
|
documentFlow: DocumentFlowStep;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
document: Document;
|
document: DocumentWithData;
|
||||||
numberOfSteps: number;
|
numberOfSteps: number;
|
||||||
onSubmit: (_data: TAddSubjectFormSchema) => void;
|
onSubmit: (_data: TAddSubjectFormSchema) => void;
|
||||||
};
|
};
|
||||||
@ -41,8 +42,8 @@ export const AddSubjectFormPartial = ({
|
|||||||
} = useForm<TAddSubjectFormSchema>({
|
} = useForm<TAddSubjectFormSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: {
|
email: {
|
||||||
subject: '',
|
subject: document.documentMeta?.subject ?? '',
|
||||||
message: '',
|
message: document.documentMeta?.message ?? '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
45
scripts/remap-vercel-env.cjs
Normal file
45
scripts/remap-vercel-env.cjs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/** @typedef {import('@documenso/tsconfig/process-env')} */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remap Vercel environment variables to our defined Next.js environment variables.
|
||||||
|
*
|
||||||
|
* @deprecated This is no longer needed because we can't inject runtime environment variables via next.config.js
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const remapVercelEnv = () => {
|
||||||
|
if (!process.env.VERCEL || !process.env.DEPLOYMENT_TARGET) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.POSTGRES_URL) {
|
||||||
|
process.env.NEXT_PRIVATE_DATABASE_URL = process.env.POSTGRES_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.POSTGRES_URL_NON_POOLING) {
|
||||||
|
process.env.NEXT_PRIVATE_DIRECT_DATABASE_URL = process.env.POSTGRES_URL_NON_POOLING;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const url = new URL(process.env.NEXT_PRIVATE_DATABASE_URL);
|
||||||
|
|
||||||
|
url.searchParams.set('pgbouncer', 'true');
|
||||||
|
|
||||||
|
process.env.NEXT_PRIVATE_DATABASE_URL = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.VERCEL_ENV !== 'production' && process.env.DEPLOYMENT_TARGET === 'webapp') {
|
||||||
|
process.env.NEXTAUTH_URL = `https://${process.env.VERCEL_URL}`;
|
||||||
|
process.env.NEXT_PUBLIC_WEBAPP_URL = `https://${process.env.VERCEL_URL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.VERCEL_ENV !== 'production' && process.env.DEPLOYMENT_TARGET === 'marketing') {
|
||||||
|
process.env.NEXT_PUBLIC_MARKETING_URL = `https://${process.env.VERCEL_URL}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
remapVercelEnv,
|
||||||
|
};
|
||||||
107
scripts/vercel.sh
Executable file
107
scripts/vercel.sh
Executable file
@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Exit on error.
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
# Get the directory of this script, regardless of where it is called from.
|
||||||
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
|
|
||||||
|
|
||||||
|
function log() {
|
||||||
|
echo "[VercelBuild]: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
function build_webapp() {
|
||||||
|
log "Building webapp for $VERCEL_ENV"
|
||||||
|
|
||||||
|
remap_database_integration
|
||||||
|
|
||||||
|
npm run prisma:generate --workspace=@documenso/prisma
|
||||||
|
npm run prisma:migrate-deploy --workspace=@documenso/prisma
|
||||||
|
|
||||||
|
if [[ "$VERCEL_ENV" != "production" ]]; then
|
||||||
|
log "Seeding database for $VERCEL_ENV"
|
||||||
|
|
||||||
|
npm run prisma:seed --workspace=@documenso/prisma
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm run build -- --filter @documenso/web
|
||||||
|
}
|
||||||
|
|
||||||
|
function remap_webapp_env() {
|
||||||
|
if [[ "$VERCEL_ENV" != "production" ]]; then
|
||||||
|
log "Remapping webapp environment variables for $VERCEL_ENV"
|
||||||
|
|
||||||
|
export NEXTAUTH_URL="https://$VERCEL_URL"
|
||||||
|
export NEXT_PUBLIC_WEBAPP_URL="https://$VERCEL_URL"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function build_marketing() {
|
||||||
|
log "Building marketing for $VERCEL_ENV"
|
||||||
|
|
||||||
|
remap_database_integration
|
||||||
|
|
||||||
|
npm run prisma:generate --workspace=@documenso/prisma
|
||||||
|
npm run build -- --filter @documenso/marketing
|
||||||
|
}
|
||||||
|
|
||||||
|
function remap_marketing_env() {
|
||||||
|
if [[ "$VERCEL_ENV" != "production" ]]; then
|
||||||
|
log "Remapping marketing environment variables for $VERCEL_ENV"
|
||||||
|
|
||||||
|
export NEXT_PUBLIC_MARKETING_URL="https://$VERCEL_URL"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
function remap_database_integration() {
|
||||||
|
log "Remapping Supabase integration for $VERCEL_ENV"
|
||||||
|
|
||||||
|
if [[ ! -z "$POSTGRES_URL" ]]; then
|
||||||
|
export NEXT_PRIVATE_DATABASE_URL="$POSTGRES_URL"
|
||||||
|
export NEXT_PRIVATE_DIRECT_DATABASE_URL="$POSTGRES_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -z "$DATABASE_URL" ]]; then
|
||||||
|
export NEXT_PRIVATE_DATABASE_URL="$DATABASE_URL"
|
||||||
|
export NEXT_PRIVATE_DIRECT_DATABASE_URL="$DATABASE_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -z "$POSTGRES_URL_NON_POOLING" ]]; then
|
||||||
|
export NEXT_PRIVATE_DATABASE_URL="$POSTGRES_URL?pgbouncer=true"
|
||||||
|
export NEXT_PRIVATE_DIRECT_DATABASE_URL="$POSTGRES_URL_NON_POOLING"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
if [[ "$NEXT_PRIVATE_DATABASE_URL" == *"neon.tech"* ]]; then
|
||||||
|
log "Remapping for Neon integration"
|
||||||
|
|
||||||
|
PROJECT_ID="$(echo "$PGHOST" | cut -d'.' -f1)"
|
||||||
|
PGBOUNCER_HOST="$(echo "$PGHOST" | sed "s/${PROJECT_ID}/${PROJECT_ID}-pooler/")"
|
||||||
|
|
||||||
|
export NEXT_PRIVATE_DATABASE_URL="postgres://${PGUSER}:${PGPASSWORD}@${PGBOUNCER_HOST}/${PGDATABASE}?pgbouncer=true"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Navigate to the root of the project.
|
||||||
|
cd "$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
# Check if the script is running on Vercel.
|
||||||
|
if [[ -z "$VERCEL" ]]; then
|
||||||
|
log "ERROR - This script must be run as part of the Vercel build process."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$DEPLOYMENT_TARGET" in
|
||||||
|
"webapp")
|
||||||
|
build_webapp
|
||||||
|
;;
|
||||||
|
"marketing")
|
||||||
|
build_marketing
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log "ERROR - Missing or invalid DEPLOYMENT_TARGET environment variable."
|
||||||
|
log "ERROR - DEPLOYMENT_TARGET must be either 'webapp' or 'marketing'."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
30
turbo.json
30
turbo.json
@ -2,8 +2,13 @@
|
|||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": [
|
||||||
"outputs": [".next/**", "!.next/cache/**"]
|
"^build"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
".next/**",
|
||||||
|
"!.next/cache/**"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"lint": {},
|
"lint": {},
|
||||||
"dev": {
|
"dev": {
|
||||||
@ -11,20 +16,22 @@
|
|||||||
"persistent": true
|
"persistent": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalDependencies": ["**/.env.*local"],
|
"globalDependencies": [
|
||||||
|
"**/.env.*local"
|
||||||
|
],
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"APP_VERSION",
|
"APP_VERSION",
|
||||||
"NEXTAUTH_URL",
|
"NEXTAUTH_URL",
|
||||||
"NEXTAUTH_SECRET",
|
"NEXTAUTH_SECRET",
|
||||||
"NEXT_PUBLIC_APP_URL",
|
"NEXT_PUBLIC_WEBAPP_URL",
|
||||||
"NEXT_PUBLIC_SITE_URL",
|
"NEXT_PUBLIC_MARKETING_URL",
|
||||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||||
"NEXT_PUBLIC_POSTHOG_HOST",
|
"NEXT_PUBLIC_POSTHOG_HOST",
|
||||||
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
||||||
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID",
|
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID",
|
||||||
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
|
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
|
||||||
"NEXT_PRIVATE_DATABASE_URL",
|
"NEXT_PRIVATE_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_NEXT_AUTH_SECRET",
|
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
||||||
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
|
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
|
||||||
"NEXT_PUBLIC_UPLOAD_TRANSPORT",
|
"NEXT_PUBLIC_UPLOAD_TRANSPORT",
|
||||||
@ -48,6 +55,15 @@
|
|||||||
"NEXT_PRIVATE_SMTP_SECURE",
|
"NEXT_PRIVATE_SMTP_SECURE",
|
||||||
"NEXT_PRIVATE_SMTP_FROM_NAME",
|
"NEXT_PRIVATE_SMTP_FROM_NAME",
|
||||||
"NEXT_PRIVATE_SMTP_FROM_ADDRESS",
|
"NEXT_PRIVATE_SMTP_FROM_ADDRESS",
|
||||||
"NEXT_PRIVATE_STRIPE_API_KEY"
|
"NEXT_PRIVATE_STRIPE_API_KEY",
|
||||||
|
|
||||||
|
"VERCEL",
|
||||||
|
"VERCEL_ENV",
|
||||||
|
"VERCEL_URL",
|
||||||
|
"DEPLOYMENT_TARGET",
|
||||||
|
"POSTGRES_URL",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"POSTGRES_PRISMA_URL",
|
||||||
|
"POSTGRES_URL_NON_POOLING"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user