Merge branch 'feat/refresh' into feat/mania

This commit is contained in:
Timur Ercan
2023-09-22 18:33:16 +02:00
committed by GitHub
88 changed files with 3783 additions and 315 deletions

View File

@ -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",
]
}
}
} }

View File

@ -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"

View File

@ -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/

View File

@ -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

View File

@ -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

View File

@ -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 worlds 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? Lets 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!)
![Documenso](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wkcujctpf86p56bju3mq.png)
Choose your GitHub profile as the owner and click on 'Create fork' to create a fork of the repo.
![Fork the Documenso repository on GitHub](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xf49r2byu9nnd1465niy.png)
### 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 projects 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.
![Create a new project on Supabase](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w5lqz771iupjyi1ekfdz.png)
### 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.
![Copy the Supabase Postgres database connection URL](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y1ldu3qrg9moednbzjij.png)
### 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.
![Create a key for Resend SMTP](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uh2rztgn09mlvecl34i5.png)
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.
![Verify your domain on Resend](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nxgie0esz530vq5a494o.png)
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.
![Create a new project on Vercel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gdy97tltpnu7vf4fc11f.png)
### 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`.
![Add Environment Variables to your Vercel project](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/aru33fk1i19h0valffow.png)
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. Youre 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
![Deploying Documenso](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/secg29j9j40o4u2oa8o8.png)
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)!

View File

@ -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);

View File

@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -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"
> >

View File

@ -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.',
}, },

View 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>
);
}

View File

@ -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`,
}; };
} }

View File

@ -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`,
}); });

View File

@ -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;

View 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>
);
};

View File

@ -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>
); );

View File

@ -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>

View File

@ -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>

View File

@ -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" />

View File

@ -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" />

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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.',
}, },

View 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>
);
}

View File

@ -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" />

View File

@ -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>

View File

@ -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,
}); });

View 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>
);
};

View File

@ -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>

View File

@ -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>

View 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>
);
};

View File

@ -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">

View File

@ -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>

View File

@ -1,7 +0,0 @@
'use client';
import { motion } from 'framer-motion';
export * from 'framer-motion';
export const MotionDiv = motion.div;

View 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>
);
}

View File

@ -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: {

View File

@ -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

Binary file not shown.

1390
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
} }

View File

@ -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

View File

@ -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">

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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>

View 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;

View 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;

View 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 }),
});
};

View 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 }),
});
};

View File

@ -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,
},
});
};

View File

@ -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,

View File

@ -13,6 +13,7 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
}, },
include: { include: {
documentData: true, documentData: true,
documentMeta: true,
}, },
}); });
}; };

View File

@ -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 });
}; };

View 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 }),
});
}),
]);
};

View File

@ -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 }),
}); });

View 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 }),
});
};

View 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,
},
});
};

View File

@ -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);

View 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));
};

View 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();
};

View 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 });
};

View File

@ -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}`;

View 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
View 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;
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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");

View File

@ -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;

View File

@ -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"
} }
} }

View File

@ -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

View 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);
});

View File

@ -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;
}; };

View File

@ -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,
});
}
}),
}); });

View File

@ -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>;

View File

@ -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;
} }
} }

View File

@ -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 ?? '',
}, },
}, },
}); });

View 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
View 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

View File

@ -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"
] ]
} }