Compare commits

..

71 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan 986030cc38 chore: failed attempt at adding custom field labels
I have spent all day on it and for some reason, I can't figure it out
2024-02-18 16:01:03 +00:00
David Nguyen 2815b1a809 feat: add enterprise billing (#939)
## Description

Add support for enterprise billing plans.

Enterprise billing plans by default get access to everything early
adopters do:
- Unlimited teams
- Unlimited documents

They will also get additional features in the future.

## Notes

Pending webhook updates to support enterprise onboarding.
Rolled back env changes `NEXT_PUBLIC_PROJECT` since it doesn't seem to
work.
2024-02-17 12:42:00 +11:00
Lucas Smith 960914aeb5 fix: undo operation on signature pad (#868)
fixes: #864
2024-02-16 22:57:14 +11:00
Lucas Smith d83769b410 chore: use unsafe effect 2024-02-16 11:56:02 +00:00
Lucas Smith a0cf2a2c75 fix: improved document-dropzone ui for small vertical screens (#857)
improved document-dropzone ui for small vertical screens (screens less
than 800px vertically)
Although it can still become congested on really small vertical screens,
but possibility is really low.

fixes: #840
2024-02-16 22:03:24 +11:00
Lucas Smith a30b73ce86 fix: update css 2024-02-16 11:02:04 +00:00
Lucas Smith 46d163d9d6 fix: highlighting issue in recipient selection (#937)
fixes: #920 

<img width="391" alt="image"
src="https://github.com/documenso/documenso/assets/75713174/08b2f5ab-4a6f-423a-a2fa-8f7b04789bb8">
2024-02-16 21:50:53 +11:00
Lucas Smith 681a89cfe1 chore: minor lint fixes (#934) 2024-02-16 21:48:45 +11:00
Lucas Smith e5f4edc120 chore: create security.txt (#878)
Adding a security.txt file enables security researchers to quickly and
easily see where they can submit security issues and know that they are
being taken serious. From the proposal website:

> "When security risks in web services are discovered by independent
security researchers who understand the severity of the risk, they often
lack the channels to disclose them properly. As a result, security
issues may be left unreported. security.txt defines a standard to help
organizations define the process for security researchers to disclose
security vulnerabilities securely.”

See also https://securitytxt.org
2024-02-16 12:34:41 +11:00
Sumit Bisht 25291b64eb fix: highlighting issue in recipient selection 2024-02-15 22:25:23 +05:30
Lucas Smith fe2093fe7c feat: add next-runtime-env (#869)
This PR adds the package
[next-runtime-env](https://github.com/expatfile/next-runtime-env/) to
populate the public environment variables at runtime.
2024-02-15 22:10:21 +11:00
Ephraim Atta-Duncan 49cddfab38 chore: lint with oxc 2024-02-15 06:11:50 +00:00
Timur Ercan 3e12a05ab8 chore: more grammar 2024-02-14 17:19:48 +01:00
Timur Ercan a76504c0a4 Merge branch 'main' into chore-security-text 2024-02-14 17:16:44 +01:00
Timur Ercan abab0c0a22 chore: grammer and format 2024-02-14 17:14:43 +01:00
Lucas Smith f72b669f67 feat: restrict app access for unverified users (#835) 2024-02-13 20:22:43 +11:00
Lucas Smith 536cafde31 Merge branch 'main' into feat/disable-access-unverified-users 2024-02-13 20:19:16 +11:00
Lucas Smith d052f02013 chore: refactor code 2024-02-13 06:01:25 +00:00
Catalin Pit 4878cf388f chore: add the missing signIn function 2024-02-13 07:53:36 +02:00
Catalin Pit 149f416be7 chore: refactor code 2024-02-13 07:50:22 +02:00
Catalin Pit c432261dd8 chore: disable button while form is submitting 2024-02-12 09:49:59 +02:00
Catalin Pit 1852aa4b05 chore: add info 2024-02-12 09:49:59 +02:00
David Nguyen a868ecf2d2 fix: restrict team verification tokens (#927)
## Description

Currently we're not restricting team transfer and email verification
tokens from flowing into the frontend.

This changes restricts it to only return the required information
instead of the whole data object.
2024-02-12 18:23:07 +11:00
David Nguyen b1bb345929 fix: redirect URL preventing document flow (#925)
## Description

Currently the document redirect URL feature is preventing documents from
being created unless a redirect URL is provided.

During the document edit flow, the redirect URL is hidden in an advanced
tab with the value of an empty string, which will always fail the
current Zod validation since `optional` requires undefined to pass.

There are multiple ways to fix this, but I think this is the easiest
method where we can assume an empty string is valid.
2024-02-12 15:23:15 +11:00
Lucas Smith 51608ed390 fix: lint issue 2024-02-12 02:02:43 +00:00
Lucas Smith 8ebef831ac Merge branch 'main' into feat/add-runtime-env 2024-02-12 12:30:35 +11:00
Lucas Smith 20e2976731 fix: build issues 2024-02-12 01:29:22 +00:00
David Nguyen 3a32bc62c5 feat: initial document audit logs implementation (#922)
Added initial implementation of document audit logs.
2024-02-12 12:04:53 +11:00
Adithya Krishna 4f990a7030 feat: redirect users upon signing completion (#888)
**Description:** 

- This PR adds a feature to redirect the users to a specific URL upon
signing
2024-02-09 13:31:01 +05:30
Adithya Krishna e26debe836 Merge branch 'main' into feat/sign-redirect 2024-02-09 12:10:20 +05:30
David Nguyen 8641884515 fix: recipients with CC role not being editable (#918)
## Description

Fixed issue where setting a recipient role as CC will prevent any
further changes as it is considered as "sent" and "signed".

## Other changes

- Prevent editing document after completed
- Removed CC and Viewers from the field recipient list since they will
never be filled
- Minor UI issues

## Checklist

- [X] I have tested these changes locally and they work as expected.
- [X] I have added/updated tests that prove the effectiveness of these
changes.
- [X] I have followed the project's coding style guidelines.
2024-02-09 12:37:17 +11:00
Lucas Smith 748bf6de6b fix: add dropped constants from merge 2024-02-08 22:12:04 +11:00
Lucas Smith d13cf743bf Merge branch 'main' into feat/add-runtime-env 2024-02-08 22:06:59 +11:00
Adithya Krishna 09b5621542 Merge branch 'main' into feat/sign-redirect 2024-02-08 12:56:42 +05:30
Lucas Smith 47b8cc598c fix: add validation and error message display 2024-02-08 04:28:16 +00:00
David Nguyen e97b9b4f1c feat: add team templates (#912) 2024-02-08 12:33:20 +11:00
David Nguyen cad48236a0 Merge branch 'main' into feat/disable-access-unverified-users 2024-02-07 16:30:22 +11:00
Adithya Krishna 1dd543247e chore: update branch
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-02-06 18:07:24 +05:30
Adithya Krishna 2636d5fd16 chore: finish and clean-up redirect post signing
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-02-06 18:04:56 +05:30
Adithya Krishna 9ed16c64d8 Merge branch 'main' of https://github.com/documenso/documenso into feat/sign-redirect 2024-02-05 13:13:16 +05:30
Adithya Krishna 94e72534e0 chore: updated redirection
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-02-05 13:13:12 +05:30
apoorv taneja c970abc871 added onchange handler 2024-02-02 20:46:54 +05:30
apoorv taneja d5b3df1648 fixed variable declaration 2024-02-02 19:32:39 +05:30
apoorv taneja 142c1c003e changed useEffect variables 2024-02-02 18:16:54 +05:30
apoorv taneja a06c628653 Merge branch 'main' of https://github.com/plxity/documenso into fix/undo-button-in-canvas 2024-02-02 17:56:58 +05:30
apoorv taneja 7ca3697303 Merge branch 'main' of https://github.com/plxity/documenso into fix/undo-button-in-canvas 2024-02-02 14:49:06 +05:30
Lucas Smith 8ac2209493 Merge branch 'main' into chore-security-text 2024-02-02 16:16:25 +11:00
Lucas Smith 9c4ec34a3c fix: add precommit step for .well-known 2024-02-02 04:00:28 +00:00
Timur Ercan 1f142e334a Merge branch 'main' into chore-security-text 2024-01-31 20:31:34 +01:00
Adithya Krishna f4c24fd944 feat: add a feature for redirecting users on signing
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-01-31 18:17:43 +05:30
Adithya Krishna 3541a805e5 chore: add migration file
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-01-31 18:16:07 +05:30
Mythie 08f82b23dc fix: update env entries to evaluate at runtime 2024-01-31 22:32:42 +11:00
Timur Ercan 747a7b0aea chore: security contacts and descr 2024-01-30 16:15:32 +01:00
Catalin Pit 6053a4a40a chore: refactor 2024-01-30 12:56:32 +02:00
Catalin Pit cc090adce0 chore: refactor 2024-01-30 12:54:48 +02:00
Timur Ercan 375df71f5c Merge branch 'main' into chore-security-text 2024-01-29 16:43:57 +01:00
Catalin Pit c0bb5205e1 chore: merged main 2024-01-29 10:14:56 +02:00
Catalin Pit 1676f5bf6c chore: removed unused code 2024-01-29 09:43:38 +02:00
Catalin Pit f514d55d27 chore: removed unused schema 2024-01-29 09:41:02 +02:00
Tangerine Kugelmann 927a656c57 Create security.txt
See also https://securitytxt.org
2024-01-28 01:00:07 +01:00
Catalin Pit 751fb5275c Merge branch 'main' into feat/add-runtime-env 2024-01-26 14:04:54 +02:00
Catalin Pit b2cca9afb6 chore: refactor 2024-01-26 13:27:36 +02:00
Catalin Pit e2fa01509d chore: avoid returning unnecessary info 2024-01-25 17:33:35 +02:00
Catalin Pit 311c8da8fc chore: encrypt and decrypt email addr 2024-01-25 17:24:37 +02:00
Catalin Pit 49ecfc1a2c chore: refactor 2024-01-25 15:42:40 +02:00
Catalin Pit ffee2b2c9a chore: merged main 2024-01-25 13:43:11 +02:00
Catalin Pit 2f18518961 chore: merged main 2024-01-25 10:53:05 +02:00
Catalin Pit d451a7acce feat: add next-runtime-env 2024-01-25 10:48:20 +02:00
apoorv taneja d8aecc4092 fixed undo operation on signature pad 2024-01-25 13:21:55 +05:30
Sumit Bisht e5c2263e92 fix: imporoved document-dropzone ui for small vertical screens 2024-01-23 18:37:02 +05:30
Catalin Pit 4aefb80989 feat: restrict app access for unverified users 2024-01-16 14:25:05 +02:00
179 changed files with 3244 additions and 57751 deletions
+12
View File
@@ -1,4 +1,16 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
echo "Copying pdf.js"
npm run copy:pdfjs --workspace apps/**
echo "Copying .well-known/ contents"
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
git add "$MONOREPO_ROOT/apps/web/public/"
git add "$MONOREPO_ROOT/apps/marketing/public/"
npx lint-staged
+7
View File
@@ -0,0 +1,7 @@
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt
@@ -1,64 +0,0 @@
---
title: Launch Week II - Day 1 - Teams
description: Teams for Documenso are here. And they come free for early adopters!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-26
tags:
- Launch Week
- teams
- early adopter perks
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/12a85ec7-20bb-4813-9714-e4da42c9cfba"
autoPlay
loop
muted
></video>
> TLDR; Docucmenso now supports teams that share documents, templates and a team mail address. Early Adopter get UNLIMITED<sup>1</sup> Users.
## Kicking off Launch Week II - "Connected"
The day has come! Roughly 5 months after kicked off our first launch week with open sourcing our design and Malfunction Mania, Launch Week #2 is here 🎉 This Launch Week's theme is "connected", since this is all about connecting humans, machines and documents.
Working with documents and getting that signature is a team sport. This is why we are kicking it off today with a very long-awaited feature: Documenso now supports teams!
## Introducing Teams for Documenso
You can now create teams next to your personal account: Simply invite your colleagues, and you can include everyone you like in working with your documents. With teams, you can:
- Send unlimited signature requests with unlimited recipients
- Create, view, edit and sign documents owned by the team
- Define a dedicated team email, to receive signing requests into a team inbox for the owner to sign
- Manage team roles: Member (Create+Edit), Manger Manage (+Manage Team Members), Owner (+Transfer Team +Delete Team + Sign Documents sent to team email)
## Pricing
Together with Teams, we are announcing the new teams pricing:
- $10 per seat per month
- 5 seats minimum
- You can add seats dynamically as needed
This pricing will take effect, as soon as the early adopter seats run out. Want to check out teams: [https://documen.so/teams](https://documen.so/teams).
## Early Adopter Perks
There is one more point on pricing I have been looking forward to for a long time:
All early adopter plans now include **UNLIMITED teams and users**<sup>1</sup> . We appreciate your support so far very much, and I'm happy to announce this first of more early adopter perks to come. We have roughly 48 early adopter plans left, so if you plan to onboard your team, now is a great time to [grab your early adopter seat.](https://documen.so/claim-early-adopters-plan)
We are eager to hear from all teams users how you like this addition and what we can add to make it even better. Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here, and we would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 1! Teams just dropped. Check it out https://documen.so/day1 🚀"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur
\
[1] Within reason. If you are unsure what that means, feel free to contact hi@documenso.com and ask for clarification if it's more than 100.
@@ -1,51 +0,0 @@
---
title: Launch Week II - Day 2 - Templates
description: Templates help you prepare regular documents faster. And you can share them with your team!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-27
tags:
- Launch Week
- secret
- no spoiler
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/c9504db1-26b7-4033-88ed-a95cabd02e92"
autoPlay
loop
muted
></video>
> TLDR; You can now reuse documents via templates. More field types coming soon as well.
## Introducing Templates
It's day 2 of Launch Week, everybody 🙌 After introducing [Teams](https://documenso.com/blog/launch-week-2-day-1) yesterday, today we are looking at making Documenso faster for daily use:
We are launching templates for Documenso! Templates are an easy to reuse documents you send out often with just a few clicks. With templates, you can:
- Save often-uploaded documents for reuse
- Define fields once and just fill them out before sending (if at all)
- Create and share templates with your team
## Pricing
Templates are **included in all Documenso Plans!** That includes our free tier: The limit of 5 documents per month still applies, but you are free to reach it with less friction using templates. Sharing templates with other users is only possible with the teams plan. If you want to share templates with people not in your team, we might have something coming up later this week 👀
## What's Next for Templates
While this is all great, we have a lot of great stuff coming up for templates:
- More Field Types
- Custom Field Types Defined by You
- Sharing Templates Externally 👀
Check out templates [here](https://documen.so/templates) and let us know what you think and what we can improve. Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 2! They just launched templates, and I'm pumped 🎉🚀🚀🚀. Check it out https://documen.so/day2"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur
@@ -1,48 +0,0 @@
---
title: Launch Week II - Day 3 - API
description: Documenso's mission is to create a plattform developers all around the world can build upon. Today we are releasing the first version of our public API, included in all plans!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-28
tags:
- Launch Week
- API
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/cb74d6cb-a127-4cac-a166-ad6b56c6140d"
autoPlay
loop
muted
></video>
> TLDR; The public API is now availible for all plans.
## Introducing the public Documenso API
Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform that developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. Since this is the first version, we focused on the basics. With the new API you can:
- Get Documents (Individual or all Accessible)
- Upload documents
- Delete Documents
- Trigger Sending Documents for Singing
You can check out the detailed API documentation here: TODO API DOCS URL
## Pricing
We are building Documenso to be an open and extendable platform; therefore, therefore the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs.
## What's next for the API
You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better?
Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 3! The public API is here 👀 Check it out https://documen.so/day3"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur
@@ -1,48 +0,0 @@
---
title: Launch Week II - Day 4 - Zapier
description: If you want to integrate Documenso without fiddling with the API, we got you as well. You can now integrate Documenso via Zapier, included in all plans!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-29
tags:
- Launch Week
- Zapier
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/3b60789d-8d27-4c66-ae5c-179e33c2e3e6"
autoPlay
loop
muted
></video>
> TLDR; The public API is now availible for all plans.
## Introducing the public Documenso API
Launch. Week. Day. 3 🎉 Documenso's mission is to create a platform, developers all around the world can build upon. Today we are releasing the first version of our public API, and we are pumped. This since this is the first version we focused on the basics. The API is With the new API you can:
- Get Documents (Individual or all Accessible)
- Upload documents
- Delete Documents
- Trigger Sending Documents for Singing
You can check out the detailed API documentation here: TODO API DOCS URL
## Pricing
We are building Documenso to be an open and extendable platform; therefore, the API is included in all current plans. The API is authenticated via auth tokens, which every user can create at no extra cost. Existing limits still apply (i.e., the number of included documents for the free plan). While we don't have all the details yet, we don't intend to price the API usage in itself (rather the accounts using it) since we want you to build on Documenso without being smothered by API costs.
## What's next for the API
You tell us. This is by far the most requested feature, so we would like to hear from you. What should we add? How can we integrate even better?
Connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here and would love to hear from you :)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 3! The public API is here 👀 Check it out https://documen.so/day3"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur
@@ -1,59 +0,0 @@
---
title: Launch Week II - Day 5 - Documenso Profiles
description: Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles will launch as soon as they are shiny.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-03-01
tags:
- Launch Week
- Profiles
---
<video
id="vid"
width="100%"
src="https://github.com/documenso/design/assets/1309312/89643ae2-2aa9-484c-a522-a0e35097c469"
autoPlay
loop
muted
></video>
> TLDR; Documenso profiles allow you to send signing links to people so they can sign anytime and see who you are. Documenso Profile Usernames can be claimed starting today. Long ones free, short ones paid. Profiles launch as soon as they are shiny.
## Introducing Documenso Profile Links
Signing documents has always been between humans, and signing something together should be as frictionless as possible. It should also be async, so you don't force your counterpart to jog to their device to send something when you are ready. Today we are announcing the new Documenso Profiles:
<figure>
<MdxNextImage
src="/blog/profile.png"
width="650"
height="100"
alt="lorem"
/>
<figcaption className="text-center">
Async > Sync: Add public templates to your Documenso Link and let people sign whenever they are ready.
</figcaption>
</figure>
Documenso profiles work with your existing templates. You can just add them to your public profile to let everyone with your link sign them. With profiles, we want to bring back the human aspect of signing.
By making profiles public, you can always access what your counterparty offers and make them more visible in the process. Long-term, we plan to add more to profiles to help you ensure the person you are dealing with is who they claim to be. Documenso wants to be the trust layer of the internet, and we want to start at the very fundamental level: The individual transaction.
Profiles are our first step towards bringing more trust into everything, simply by making the use of signing more frictionless. As there is more and more content of questionable origin out there, we want to support you in making it clear what you send out and what not.
## Pricing and Claiming
Documenso profile username can be claimed starting today. Documenso profiles will launch as soon as we are happy with the details ✨
- Long usernames (6 characters or more) come free with every account, e.g. **documenso.com/u/timurercan**
- Short usernames (5 characters or fewer) or less require any paid account ([Early Adopter](https://documen.so/claim-early-adopters-plan), [Teams](https://documen.so/teams) or Enterprise): **e.g., documenso.com/u/timur**
You can claim your username here: [https://documen.so/claim](https://documen.so/claim)
> 🚨 We need you help to help us to make this the biggest launch week yet: <a href="https://twitter.com/intent/tweet?text=It's @Documenso Launch Week Day 5! You can now claim your username for the upcoming profile links 😮 https://documen.so/day5"> Support us on Twitter </a> or anywhere to spread awareness for open signing! The best posts will receive merch codes 👀
Best from Hamburg\
Timur
@@ -0,0 +1,7 @@
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

+1 -56590
View File
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
const document = allDocuments.find((post) => post._raw.flattenedPath === params.content);
if (!document) {
notFound();
return { title: 'Not Found' };
}
return { title: document.title };
@@ -7,6 +7,8 @@ import { ChevronLeft } from 'lucide-react';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
export const dynamic = 'force-dynamic';
export const generateStaticParams = () =>
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
@@ -14,7 +16,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
if (!blogPost) {
notFound();
return {
title: 'Not Found',
};
}
return {
@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
import { ArrowRight } from 'lucide-react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
@@ -12,6 +13,8 @@ import { Button } from '@documenso/ui/primitives/button';
import { PasswordReveal } from '~/components/(marketing)/password-reveal';
export const dynamic = 'force-dynamic';
const fontCaveat = Caveat({
weight: ['500'],
subsets: ['latin'],
@@ -175,11 +178,7 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
This is a temporary password. Please change it as soon as possible.
</p>
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signin`}
target="_blank"
className="mt-4 block"
>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signin`} target="_blank" className="mt-4 block">
<Button size="lg" className="text-base">
Let's get started!
<ArrowRight className="ml-2 h-5 w-5" />
@@ -147,7 +147,12 @@ export default async function OpenPage() {
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
All our metrics, finances, and learnings are public. We believe in transparency and want
to share our journey with you. You can read more about why here:{' '}
<a className="font-bold" href="https://documenso.com/blog/pre-seed" target="_blank">
<a
className="font-bold"
href="https://documenso.com/blog/pre-seed"
target="_blank"
rel="noreferrer"
>
Announcing Open Metrics
</a>
</p>
@@ -15,6 +15,8 @@ export const metadata: Metadata = {
title: 'Pricing',
};
export const dynamic = 'force-dynamic';
export type PricingPageProps = {
searchParams?: {
planId?: string;
@@ -53,7 +55,7 @@ export default function PricingPage() {
<div className="mt-4 flex justify-center">
<Button variant="outline" size="lg" className="rounded-full hover:cursor-pointer" asChild>
<Link href="https://github.com/documenso/documenso" target="_blank">
<Link href="https://github.com/documenso/documenso" target="_blank" rel="noreferrer">
Get Started
</Link>
</Button>
@@ -166,6 +168,7 @@ export default function PricingPage() {
<Link
className="text-documenso-700 font-bold"
target="_blank"
rel="noreferrer"
href="mailto:support@documenso.com"
>
support@documenso.com
@@ -175,6 +178,7 @@ export default function PricingPage() {
className="text-documenso-700 font-bold"
href="https://documen.so/discord"
target="_blank"
rel="noreferrer"
>
in our Discord-Support-Channel
</a>{' '}
@@ -6,6 +6,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client';
@@ -85,6 +86,7 @@ export const SinglePlayerClient = () => {
setFields(
data.fields.map((field, i) => ({
id: i,
secondaryId: i.toString(),
documentId: -1,
templateId: null,
recipientId: -1,
@@ -189,7 +191,7 @@ export const SinglePlayerClient = () => {
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '}
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
@@ -7,6 +7,7 @@ export const metadata: Metadata = {
};
export const revalidate = 0;
export const dynamic = 'force-dynamic';
// !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during
+28 -24
View File
@@ -3,6 +3,7 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -17,32 +18,35 @@ import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
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.',
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
export function generateMetadata() {
return {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
metadataBase: new URL(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3000'),
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: ['/opengraph-image.jpg'],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: ['/opengraph-image.jpg'],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getAllAnonymousFlags();
@@ -8,6 +8,7 @@ import Link from 'next/link';
import { AnimatePresence, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -82,11 +83,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p>
<Button className="rounded-full text-base" asChild>
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
target="_blank"
className="mt-6"
>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank" className="mt-6">
Signup Now
</Link>
</Button>
@@ -117,13 +114,13 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p>
<Button className="mt-6 rounded-full text-base" asChild>
<Link href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}>Signup Now</Link>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}>Signup Now</Link>
</Button>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4 font-medium">
{' '}
<a href="https://documenso.com/blog/early-adopters" target="_blank">
<a href="https://documenso.com/blog/early-adopters" target="_blank" rel="noreferrer">
The Early Adopter Deal:
</a>
</p>
@@ -133,7 +130,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
<p className="text-foreground py-4">
<strong>
{' '}
<a href="https://documenso.com/blog/early-adopters" target="_blank">
<a
href="https://documenso.com/blog/early-adopters"
target="_blank"
rel="noreferrer"
>
Includes all upcoming features
</a>
</strong>
@@ -6,6 +6,7 @@ import Link from 'next/link';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { Signature } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
@@ -85,7 +86,7 @@ export const SinglePlayerModeSuccess = ({
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
Create a{' '}
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
target="_blank"
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
>
@@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { env } from 'next-runtime-env';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -144,7 +145,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
setTimeout(resolve, 1000);
});
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
const planId = env('NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID');
if (!planId) {
throw new Error('No plan ID found.');
}
const claimPlanInput = signatureDataUrl
? {
@@ -1,13 +1,15 @@
import { NextApiRequest, NextApiResponse } from 'next';
import type { NextApiRequest, NextApiResponse } from 'next';
import { randomUUID } from 'crypto';
import { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata';
import type { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { TClaimPlanResponseSchema, ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
import type { TClaimPlanResponseSchema } from '~/api/claim-plan/types';
import { ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
export default async function handler(
req: NextApiRequest,
@@ -40,7 +42,7 @@ export default async function handler(
if (user) {
return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/signin`,
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/signin`,
});
}
@@ -77,8 +79,8 @@ export default async function handler(
mode: 'subscription',
metadata,
allow_promotion_codes: true,
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}`,
success_url: `${NEXT_PUBLIC_MARKETING_URL()}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
cancel_url: `${NEXT_PUBLIC_MARKETING_URL()}`,
});
if (!checkout.url) {
+7
View File
@@ -0,0 +1,7 @@
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt
@@ -25,7 +25,7 @@ export type DocumentPageViewProps = {
team?: Team;
};
export default async function DocumentPageView({ params, team }: DocumentPageViewProps) {
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
const { id } = params;
const documentId = Number(id);
@@ -128,4 +128,4 @@ export default async function DocumentPageView({ params, team }: DocumentPageVie
)}
</div>
);
}
};
@@ -151,7 +151,7 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message, timezone, dateFormat } = data.meta;
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
try {
await sendDocument({
@@ -159,8 +159,9 @@ export const EditDocumentForm = ({
meta: {
subject,
message,
timezone,
dateFormat,
timezone,
redirectUrl,
},
});
@@ -1,4 +1,4 @@
import DocumentPageView from './document-page-view';
import { DocumentPageView } from './document-page-view';
export type DocumentPageProps = {
params: {
@@ -108,88 +108,86 @@ export const ResendDocumentActionItem = ({
};
return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
</DialogTrigger>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
</DialogTitle>
</DialogHeader>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem
key={recipient.id}
className="flex flex-row items-center justify-between gap-x-3"
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem
key={recipient.id}
className="flex flex-row items-center justify-between gap-x-3"
>
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
checkClassName="text-white"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
checkClassName="text-white"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
Send reminder
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
Cancel
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
Send reminder
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -114,7 +114,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
{recipient?.role !== RecipientRole.CC && (
{recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
@@ -33,10 +33,7 @@ export type DocumentsPageViewProps = {
team?: Team & { teamEmail?: TeamEmail | null };
};
export default async function DocumentsPageView({
searchParams = {},
team,
}: DocumentsPageViewProps) {
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
const { user } = await getRequiredServerComponentSession();
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
@@ -155,4 +152,4 @@ export default async function DocumentsPageView({
</div>
</div>
);
}
};
@@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import type { DocumentsPageViewProps } from './documents-page-view';
import DocumentsPageView from './documents-page-view';
import { DocumentsPageView } from './documents-page-view';
export type DocumentsPageProps = {
searchParams?: DocumentsPageViewProps['searchParams'];
@@ -117,7 +117,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
return (
<div className={cn('relative', className)}>
<DocumentDropzone
className="min-h-[40vh]"
className="h-[min(400px,50vh)]"
disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
@@ -2,6 +2,7 @@
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
export const createBillingPortal = async () => {
@@ -11,6 +12,6 @@ export const createBillingPortal = async () => {
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
});
};
@@ -3,6 +3,7 @@
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
@@ -27,13 +28,13 @@ export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
if (foundSubscription) {
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
});
}
return getCheckoutSession({
customerId: stripeCustomer.id,
priceId,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
});
};
@@ -5,7 +5,7 @@ import { match } from 'ts-pattern';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
@@ -37,23 +37,23 @@ export default async function BillingSettingsPage() {
user = await getStripeCustomerByUser(user).then((result) => result.user);
}
const [subscriptions, prices, communityPlanPrices] = await Promise.all([
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
getPrimaryAccountPlanPrices(),
]);
const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id);
const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
communityPlanPriceIds.includes(priceId),
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
primaryAccountPlanPriceIds.includes(priceId),
);
const subscription =
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
communityPlanUserSubscriptions[0];
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
primaryAccountPlanSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
@@ -28,6 +28,7 @@ export type EditTemplateFormProps = {
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
templateRootPath: string;
};
type EditTemplateStep = 'signers' | 'fields';
@@ -40,6 +41,7 @@ export const EditTemplateForm = ({
fields,
user: _user,
documentData,
templateRootPath,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
@@ -98,7 +100,7 @@ export const EditTemplateForm = ({
duration: 5000,
});
router.push('/templates');
router.push(templateRootPath);
} catch (err) {
toast({
title: 'Error',
@@ -1,81 +1,10 @@
import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import type { TemplatePageViewProps } from './template-page-view';
import { TemplatePageView } from './template-page-view';
import { ChevronLeft } from 'lucide-react';
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { TemplateType } from '~/components/formatter/template-type';
import { EditTemplateForm } from './edit-template';
export type TemplatePageProps = {
params: {
id: string;
};
};
export default async function TemplatePage({ params }: TemplatePageProps) {
const { id } = params;
const templateId = Number(id);
if (!templateId || Number.isNaN(templateId)) {
redirect('/documents');
}
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
id: templateId,
userId: user.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
redirect('/documents');
}
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
templateId,
userId: user.id,
}),
getFieldsForTemplate({
templateId,
userId: user.id,
}),
]);
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
</div>
<EditTemplateForm
className="mt-8"
template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
/>
</div>
);
export default function TemplatePage({ params }: TemplatePageProps) {
return <TemplatePageView params={params} />;
}
@@ -0,0 +1,86 @@
import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { TemplateType } from '~/components/formatter/template-type';
import { EditTemplateForm } from './edit-template';
export type TemplatePageViewProps = {
params: {
id: string;
};
team?: Team;
};
export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) => {
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
redirect(templateRootPath);
}
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
id: templateId,
userId: user.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
redirect(templateRootPath);
}
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
templateId,
userId: user.id,
}),
getFieldsForTemplate({
templateId,
userId: user.id,
}),
]);
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
</div>
<EditTemplateForm
className="mt-8"
template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
templateRootPath={templateRootPath}
/>
</div>
);
};
@@ -21,9 +21,15 @@ import { DuplicateTemplateDialog } from './duplicate-template-dialog';
export type DataTableActionDropdownProps = {
row: Template;
templateRootPath: string;
teamId?: number;
};
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
export const DataTableActionDropdown = ({
row,
templateRootPath,
teamId,
}: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -34,6 +40,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
}
const isOwner = row.userId === session.user.id;
const isTeamTemplate = row.teamId === teamId;
return (
<DropdownMenu>
@@ -44,20 +51,25 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner} asChild>
<Link href={`/templates/${row.id}`}>
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<Link href={`${templateRootPath}/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDuplicateDialogOpen(true)}
>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
@@ -65,6 +77,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DuplicateTemplateDialog
id={row.id}
teamId={teamId}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>
@@ -28,6 +28,9 @@ type TemplatesDataTableProps = {
perPage: number;
page: number;
totalPages: number;
documentRootPath: string;
templateRootPath: string;
teamId?: number;
};
export const TemplatesDataTable = ({
@@ -35,6 +38,9 @@ export const TemplatesDataTable = ({
perPage,
page,
totalPages,
documentRootPath,
templateRootPath,
teamId,
}: TemplatesDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
@@ -70,7 +76,7 @@ export const TemplatesDataTable = ({
duration: 5000,
});
router.push(`/documents/${id}`);
router.push(`${documentRootPath}/${id}`);
} catch (err) {
toast({
title: 'Error',
@@ -131,7 +137,12 @@ export const TemplatesDataTable = ({
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
Use Template
</Button>
<DataTableActionDropdown row={row.original} />
<DataTableActionDropdown
row={row.original}
teamId={teamId}
templateRootPath={templateRootPath}
/>
</div>
);
},
@@ -35,20 +35,15 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
onOpenChange(false);
},
});
const onDeleteTemplate = async () => {
try {
await deleteTemplate({ id });
} catch {
onError: () => {
toast({
title: 'Something went wrong',
description: 'This template could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
@@ -63,20 +58,18 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
</DialogHeader>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="button"
variant="secondary"
disabled={isLoading}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
Delete
</Button>
</div>
<Button type="button" loading={isLoading} onClick={async () => deleteTemplate({ id })}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -14,12 +14,14 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateTemplateDialogProps = {
id: number;
teamId?: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DuplicateTemplateDialog = ({
id,
teamId,
open,
onOpenChange,
}: DuplicateTemplateDialogProps) => {
@@ -40,22 +42,15 @@ export const DuplicateTemplateDialog = ({
onOpenChange(false);
},
onError: () => {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
},
});
const onDuplicate = async () => {
try {
await duplicateTemplate({
templateId: id,
});
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
@@ -66,20 +61,27 @@ export const DuplicateTemplateDialog = ({
</DialogHeader>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="button"
disabled={isLoading}
variant="secondary"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
Duplicate
</Button>
</div>
<Button
type="button"
loading={isLoading}
onClick={async () =>
duplicateTemplate({
templateId: id,
teamId,
})
}
>
Duplicate
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -43,8 +43,14 @@ const ZCreateTemplateFormSchema = z.object({
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
export const NewTemplateDialog = () => {
type NewTemplateDialogProps = {
teamId?: number;
templateRootPath: string;
};
export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => {
const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast();
@@ -99,6 +105,7 @@ export const NewTemplateDialog = () => {
});
const { id } = await createTemplate({
teamId,
title: values.name ? values.name : file.name,
templateDocumentDataId,
});
@@ -112,7 +119,7 @@ export const NewTemplateDialog = () => {
setShowNewTemplateDialog(false);
void router.push(`/templates/${id}`);
router.push(`${templateRootPath}/${id}`);
} catch {
toast({
title: 'Something went wrong',
@@ -2,57 +2,17 @@ import React from 'react';
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
import { TemplatesDataTable } from './data-table-templates';
import { EmptyTemplateState } from './empty-state';
import { NewTemplateDialog } from './new-template-dialog';
import { TemplatesPageView } from './templates-page-view';
import type { TemplatesPageViewProps } from './templates-page-view';
type TemplatesPageProps = {
searchParams?: {
page?: number;
perPage?: number;
};
searchParams?: TemplatesPageViewProps['searchParams'];
};
export const metadata: Metadata = {
title: 'Templates',
};
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
const { user } = await getRequiredServerComponentSession();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
const { templates, totalPages } = await getTemplates({
userId: user.id,
page: page,
perPage: perPage,
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">
<h1 className="mb-5 mt-2 truncate text-2xl font-semibold md:text-3xl">Templates</h1>
<div>
<NewTemplateDialog />
</div>
</div>
<div className="relative">
{templates.length > 0 ? (
<TemplatesDataTable
templates={templates}
page={page}
perPage={perPage}
totalPages={totalPages}
/>
) : (
<EmptyTemplateState />
)}
</div>
</div>
);
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
return <TemplatesPageView searchParams={searchParams} />;
}
@@ -0,0 +1,73 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { TemplatesDataTable } from './data-table-templates';
import { EmptyTemplateState } from './empty-state';
import { NewTemplateDialog } from './new-template-dialog';
export type TemplatesPageViewProps = {
searchParams?: {
page?: number;
perPage?: number;
};
team?: Team;
};
export const TemplatesPageView = async ({ searchParams = {}, team }: TemplatesPageViewProps) => {
const { user } = await getRequiredServerComponentSession();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
const documentRootPath = formatDocumentsPath(team?.url);
const templateRootPath = formatTemplatesPath(team?.url);
const { templates, totalPages } = await findTemplates({
userId: user.id,
teamId: team?.id,
page: page,
perPage: perPage,
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="truncate text-2xl font-semibold md:text-3xl">Templates</h1>
</div>
<div>
<NewTemplateDialog templateRootPath={templateRootPath} teamId={team?.id} />
</div>
</div>
<div className="relative mt-5">
{templates.length > 0 ? (
<TemplatesDataTable
templates={templates}
page={page}
perPage={perPage}
totalPages={totalPages}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
teamId={team?.id}
/>
) : (
<EmptyTemplateState />
)}
</div>
</div>
);
};
@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server';
import { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { ShareHandlerAPIResponse } from '~/pages/api/share';
export const runtime = 'edge';
@@ -37,7 +39,7 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
),
]);
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const baseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const recipientOrSender: ShareHandlerAPIResponse = await fetch(
new URL(`/api/share?slug=${slug}`, baseUrl),
@@ -1,8 +1,8 @@
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
type SharePageProps = {
params: { slug: string };
@@ -16,12 +16,12 @@ export function generateMetadata({ params: { slug } }: SharePageProps) {
title: 'Documenso - Join the open source signing revolution',
description: 'I just signed with Documenso!',
type: 'website',
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
images: [`/share/${slug}/opengraph`],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
images: [`/share/${slug}/opengraph`],
description: 'I just signed with Documenso!',
},
} satisfies Metadata;
@@ -35,5 +35,5 @@ export default function SharePage() {
return null;
}
redirect(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001');
redirect(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001');
}
@@ -26,9 +26,10 @@ export type SigningFormProps = {
document: Document;
recipient: Recipient;
fields: Field[];
redirectUrl?: string | null;
};
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
export const SigningForm = ({ document, recipient, fields, redirectUrl }: SigningFormProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
@@ -74,7 +75,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
timestamp: new Date().toISOString(),
});
router.push(`/sign/${recipient.token}/complete`);
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${recipient.token}/complete`);
};
return (
@@ -118,7 +118,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
<span className="text-muted-foreground">({recipient.email})</span>
</DialogTitle>
<div className="py-4">
<div>
<Label htmlFor="signature">Full Name</Label>
<Input
@@ -1,3 +1,4 @@
import { headers } from 'next/headers';
import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
@@ -8,12 +9,12 @@ import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@@ -40,24 +41,26 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound();
}
const requestHeaders = Object.fromEntries(headers().entries());
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token }).catch(() => null),
viewedDocument({ token, requestMetadata }).catch(() => null),
]);
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
if (!document || !document.documentData || !recipient) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const { documentData } = document;
const { documentData, documentMeta } = document;
const { user } = await getServerComponentSession();
@@ -65,7 +68,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
document.status === DocumentStatus.COMPLETED ||
recipient.signingStatus === SigningStatus.SIGNED
) {
redirect(`/sign/${token}/complete`);
documentMeta?.redirectUrl
? redirect(documentMeta.redirectUrl)
: redirect(`/sign/${token}/complete`);
}
if (documentMeta?.password) {
@@ -133,7 +138,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
</Card>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<SigningForm document={document} recipient={recipient} fields={fields} />
<SigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
/>
</div>
</div>
@@ -1,7 +1,7 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import DocumentPageComponent from '~/app/(dashboard)/documents/[id]/document-page-view';
import { DocumentPageView } from '~/app/(dashboard)/documents/[id]/document-page-view';
export type DocumentPageProps = {
params: {
@@ -16,5 +16,5 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <DocumentPageComponent params={params} team={team} />;
return <DocumentPageView params={params} team={team} />;
}
@@ -2,7 +2,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import type { DocumentsPageViewProps } from '~/app/(dashboard)/documents/documents-page-view';
import DocumentsPageView from '~/app/(dashboard)/documents/documents-page-view';
import { DocumentsPageView } from '~/app/(dashboard)/documents/documents-page-view';
export type TeamsDocumentPageProps = {
params: {
@@ -18,7 +18,7 @@ export type TeamTransferStatusProps = {
className?: string;
currentUserTeamRole: TeamMemberRole;
teamId: number;
transferVerification: TeamTransferVerification | null;
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
};
export const TeamTransferStatus = ({
@@ -0,0 +1,22 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import type { TemplatePageViewProps } from '~/app/(dashboard)/templates/[id]/template-page-view';
import { TemplatePageView } from '~/app/(dashboard)/templates/[id]/template-page-view';
type TeamTemplatePageProps = {
params: TemplatePageViewProps['params'] & {
teamUrl: string;
};
};
export default async function TeamTemplatePage({ params }: TeamTemplatePageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <TemplatePageView params={params} team={team} />;
}
@@ -0,0 +1,26 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import type { TemplatesPageViewProps } from '~/app/(dashboard)/templates/templates-page-view';
import { TemplatesPageView } from '~/app/(dashboard)/templates/templates-page-view';
type TeamTemplatesPageProps = {
searchParams?: TemplatesPageViewProps['searchParams'];
params: {
teamUrl: string;
};
};
export default async function TeamTemplatesPage({
searchParams = {},
params,
}: TeamTemplatesPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
return <TemplatesPageView searchParams={searchParams} team={team} />;
}
@@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
@@ -18,6 +20,8 @@ type SignInPageProps = {
};
export default function SignInPage({ searchParams }: SignInPageProps) {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
@@ -39,7 +43,7 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<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">
@@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
@@ -18,7 +20,9 @@ type SignUpPageProps = {
};
export default function SignUpPage({ searchParams }: SignUpPageProps) {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
redirect('/signin');
}
@@ -0,0 +1,27 @@
import { Mails } from 'lucide-react';
import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email';
export default function UnverifiedAccount() {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
</div>
<div className="">
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
<p className="text-muted-foreground mt-4">
To gain access to your account, please confirm your email address by clicking on the
confirmation link from your inbox.
</p>
<p className="text-muted-foreground mt-4">
If you don't find the confirmation link in your inbox, you can request a new one below.
</p>
<SendConfirmationEmailForm />
</div>
</div>
);
}
+31 -24
View File
@@ -2,8 +2,11 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { TrpcProvider } from '@documenso/trpc/react';
@@ -19,32 +22,35 @@ import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
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.',
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
export function generateMetadata() {
return {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
metadataBase: new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'),
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: ['/opengraph-image.jpg'],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: ['/opengraph-image.jpg'],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags();
@@ -62,6 +68,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<PublicEnvScript />
</head>
<Suspense>
@@ -4,6 +4,7 @@ import React from 'react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
@@ -25,7 +26,7 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
return;
}
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.',
@@ -52,24 +52,22 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
{...props}
>
<div className="flex items-baseline gap-x-6">
{navigationLinks
.filter(({ href }) => href !== '/templates' || rootHref === '') // Remove templates for team pages.
.map(({ href, label }) => (
<Link
key={href}
href={`${rootHref}${href}`}
className={cn(
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
`${rootHref}${href}`,
),
},
)}
>
{label}
</Link>
))}
{navigationLinks.map(({ href, label }) => (
<Link
key={href}
href={`${rootHref}${href}`}
className={cn(
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
`${rootHref}${href}`,
),
},
)}
>
{label}
</Link>
))}
</div>
<CommandMenu open={open} onOpenChange={setOpen} />
@@ -6,7 +6,7 @@ import { usePathname } from 'next/navigation';
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
import { signOut } from 'next-auth/react';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
@@ -71,6 +71,22 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role];
};
/**
* Formats the redirect URL so we can switch between documents and templates page
* seemlessly between teams and personal accounts.
*/
const formatRedirectUrlOnSwitch = (teamUrl?: string) => {
const baseUrl = teamUrl ? `/t/${teamUrl}/` : '/';
const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, '');
if (currentPathname === '/templates') {
return `${baseUrl}templates`;
}
return baseUrl;
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -100,7 +116,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
<DropdownMenuLabel>Personal</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href="/">
<Link href={formatRedirectUrlOnSwitch()}>
<AvatarWithText
avatarFallback={formatAvatarFallback()}
primaryText={user.name}
@@ -152,7 +168,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
{teams.map((team) => (
<DropdownMenuItem asChild key={team.id}>
<Link href={`/t/${team.url}`}>
<Link href={formatRedirectUrlOnSwitch(team.url)}>
<AvatarWithText
avatarFallback={formatAvatarFallback(team.name)}
primaryText={team.name}
@@ -42,7 +42,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
href: '/settings/profile',
text: 'Settings',
},
].filter(({ text, href }) => text !== 'Templates' || href === '/templates'); // Filter out templates for teams.
];
return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
@@ -238,7 +238,7 @@ export const TransferTeamDialog = ({
<Alert variant="neutral">
<AlertDescription>
<ul className="list-outside list-disc space-y-2 pl-4">
{IS_BILLING_ENABLED && (
{IS_BILLING_ENABLED() && (
// Temporary removed.
// <li>
// {form.getValues('clearPaymentMethods')
@@ -48,7 +48,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
{IS_BILLING_ENABLED && (
{IS_BILLING_ENABLED() && (
<Link href={billingPath}>
<Button
variant="ghost"
@@ -56,7 +56,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
{IS_BILLING_ENABLED && (
{IS_BILLING_ENABLED() && (
<Link href={billingPath}>
<Button
variant="ghost"
@@ -67,7 +67,7 @@ export const TeamsMemberPageDataTable = ({
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
<TabsList>
<TabsTrigger className="min-w-[60px]" value="members" asChild>
<Link href={pathname ?? '/'}>All</Link>
<Link href={pathname ?? '/'}>Active</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
@@ -0,0 +1,95 @@
'use client';
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSendConfirmationEmailFormSchema = z.object({
email: z.string().email().min(1),
});
export type TSendConfirmationEmailFormSchema = z.infer<typeof ZSendConfirmationEmailFormSchema>;
export type SendConfirmationEmailFormProps = {
className?: string;
};
export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFormProps) => {
const { toast } = useToast();
const form = useForm<TSendConfirmationEmailFormSchema>({
values: {
email: '',
},
resolver: zodResolver(ZSendConfirmationEmailFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation();
const onFormSubmit = async ({ email }: TSendConfirmationEmailFormSchema) => {
try {
await sendConfirmationEmail({ email });
toast({
title: 'Confirmation email sent',
description:
'A confirmation email has been sent, and it should arrive in your inbox shortly.',
duration: 5000,
});
form.reset();
} catch (err) {
toast({
title: 'An error occurred while sending your confirmation email',
description: 'Please try again and make sure you enter the correct email address.',
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form
className={cn('mt-6 flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email address</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormMessage />
<Button size="lg" type="submit" disabled={isSubmitting} loading={isSubmitting}>
Send confirmation email
</Button>
</fieldset>
</form>
</Form>
);
};
+16
View File
@@ -2,6 +2,8 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@@ -38,6 +40,8 @@ const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
'This account appears to be using a social login method, please sign in using that method',
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
[ErrorCode.UNVERIFIED_EMAIL]:
'This account has not been verified. Please verify your account before signing in.',
};
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
@@ -63,6 +67,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
const { toast } = useToast();
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
const router = useRouter();
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
@@ -130,6 +135,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
const errorMessage = ERROR_MESSAGES[result.error];
if (result.error === ErrorCode.UNVERIFIED_EMAIL) {
router.push(`/unverified-account`);
toast({
title: 'Unable to sign in',
description: errorMessage ?? 'An unknown error occurred',
});
return;
}
toast({
variant: 'destructive',
title: 'Unable to sign in',
+10 -4
View File
@@ -1,5 +1,7 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@@ -55,6 +57,7 @@ export type SignUpFormProps = {
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
const { toast } = useToast();
const analytics = useAnalytics();
const router = useRouter();
const form = useForm<TSignUpFormSchema>({
values: {
@@ -74,10 +77,13 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
try {
await signup({ name, email, password, signature });
await signIn('credentials', {
email,
password,
callbackUrl: SIGN_UP_REDIRECT_PATH,
router.push(`/unverified-account`);
toast({
title: 'Registration Successful',
description:
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
duration: 5000,
});
analytics.capture('App: User Sign Up', {
+3 -1
View File
@@ -1,3 +1,5 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/**
* getAssetBuffer is used to retrieve array buffers for various assets
* that are hosted in the `public` folder.
@@ -8,7 +10,7 @@
* @param path The path to the asset, relative to the `public` folder.
*/
export const getAssetBuffer = async (path: string) => {
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const baseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
return fetch(new URL(path, baseUrl)).then(async (res) => res.arrayBuffer());
};
+3 -3
View File
@@ -1,7 +1,7 @@
/** @type {import('lint-staged').Config} */
module.exports = {
'**/*.{ts,tsx,cts,mts}': (files) => `eslint --fix ${files.join(' ')}`,
'**/*.{js,jsx,cjs,mjs}': (files) => `prettier --write ${files.join(' ')}`,
'**/*.{yml,mdx}': (files) => `prettier --write ${files.join(' ')}`,
'**/*.{ts,tsx,cts,mts}': (files) => files.map((file) => `eslint --fix ${file}`),
'**/*.{js,jsx,cjs,mjs}': (files) => files.map((file) => `prettier --write ${file}`),
'**/*.{yml,mdx}': (files) => files.map((file) => `prettier --write ${file}`),
'**/*/package.json': 'npm run precommit',
};
+27 -2
View File
@@ -9,6 +9,9 @@
"apps/*",
"packages/*"
],
"dependencies": {
"next-runtime-env": "^3.2.0"
},
"devDependencies": {
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
@@ -14404,6 +14407,19 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/next-runtime-env": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/next-runtime-env/-/next-runtime-env-3.2.0.tgz",
"integrity": "sha512-rwe3flUgSRm51hzRN4Vt5MMSYMS4aDMEPJa0r+CMONA3UyUZl8Y5O8zjHSIlaNb3yquTCttZ0ahObPyPprBj9g==",
"dependencies": {
"next": "^14",
"react": "^18"
},
"peerDependencies": {
"next": "^14",
"react": "^18"
}
},
"node_modules/next-themes": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz",
@@ -14503,6 +14519,7 @@
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz",
"integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==",
"peer": true,
"engines": {
"node": ">=6.0.0"
}
@@ -19495,14 +19512,14 @@
"@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6",
"nodemailer": "^6.9.3",
"nodemailer": "^6.9.9",
"react-email": "^1.9.5",
"resend": "^2.0.0"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.8",
"@types/nodemailer": "^6.4.14",
"tsup": "^7.1.0"
}
},
@@ -19520,6 +19537,14 @@
"node": ">=16.0.0"
}
},
"packages/email/node_modules/nodemailer": {
"version": "6.9.9",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz",
"integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==",
"engines": {
"node": ">=6.0.0"
}
},
"packages/eslint-config": {
"name": "@documenso/eslint-config",
"version": "0.0.0",
+3 -1
View File
@@ -47,7 +47,9 @@
"apps/*",
"packages/*"
],
"dependencies": {},
"dependencies": {
"next-runtime-env": "^3.2.0"
},
"overrides": {
"next-auth": {
"next": "14.0.3"
@@ -0,0 +1,205 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEMPLATES]: view templates', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: owner.id,
teamId: team.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 2',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Owner should see both team templates.
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
await expect(page.getByRole('main')).toContainText('Showing 2 results');
// Only should only see their personal template.
await page.goto(`${WEBAPP_BASE_URL}/templates`);
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await unseedTeam(team.url);
});
test('[TEMPLATES]: delete template', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: owner.id,
teamId: team.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 2',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Owner should be able to delete their personal template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Template deleted').first()).toBeVisible();
// Team member should be able to delete all templates.
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
for (const template of ['Team template 1', 'Team template 2']) {
await page
.getByRole('row', { name: template })
.getByRole('cell', { name: 'Use Template' })
.getByRole('button')
.nth(1)
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Template deleted').first()).toBeVisible();
}
await unseedTeam(team.url);
});
test('[TEMPLATES]: duplicate template', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Duplicate personal template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Duplicate' }).click();
await expect(page.getByText('Template duplicated').first()).toBeVisible();
await expect(page.getByRole('main')).toContainText('Showing 2 results');
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
// Duplicate team template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Duplicate' }).click();
await expect(page.getByText('Template duplicated').first()).toBeVisible();
await expect(page.getByRole('main')).toContainText('Showing 2 results');
await unseedTeam(team.url);
});
test('[TEMPLATES]: use template', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
// Use team template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`);
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await unseedTeam(team.url);
});
+1 -1
View File
@@ -12,7 +12,7 @@ export type GetLimitsOptions = {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {};
const url = new URL(`${APP_BASE_URL}/api/limits`);
const url = new URL('/api/limits', APP_BASE_URL() ?? 'http://localhost:3000');
if (teamId) {
requestHeaders['team-id'] = teamId.toString();
+5 -5
View File
@@ -1,11 +1,10 @@
import { DateTime } from 'luxon';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { getPricesByPlan } from '../stripe/get-prices-by-plan';
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema';
@@ -16,7 +15,7 @@ export type GetServerLimitsOptions = {
};
export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => {
if (!IS_BILLING_ENABLED) {
if (!IS_BILLING_ENABLED()) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
@@ -56,10 +55,11 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
);
if (activeSubscriptions.length > 0) {
const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
const documentPlanPrices = await getDocumentRelatedPrices();
for (const subscription of activeSubscriptions) {
const price = communityPlanPrices.find((price) => price.id === subscription.priceId);
const price = documentPlanPrices.find((price) => price.id === subscription.priceId);
if (!price || typeof price.product === 'string' || price.product.deleted) {
continue;
}
@@ -0,0 +1,10 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of documents a user can create.
*/
export const getDocumentRelatedPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};
@@ -0,0 +1,13 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
export const getEnterprisePlanPrices = async () => {
return await getPricesByPlan(STRIPE_PLAN_TYPE.ENTERPRISE);
};
export const getEnterprisePlanPriceIds = async () => {
const prices = await getEnterprisePlanPrices();
return prices.map((price) => price.id);
};
@@ -1,14 +1,18 @@
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
export const getPricesByPlan = async (
plan: (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE],
) => {
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
const planTypes = typeof plan === 'string' ? [plan] : plan;
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
const { data: prices } = await stripe.prices.search({
query: `metadata['plan']:'${plan}' type:'recurring'`,
query,
expand: ['data.product'],
limit: 100,
});
return prices;
return prices.filter((price) => price.type === 'recurring');
};
@@ -0,0 +1,10 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the prices of items that count as the account's primary plan.
*/
export const getPrimaryAccountPlanPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};
@@ -0,0 +1,17 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};
/**
* Returns the Stripe price IDs of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPriceIds = async () => {
return await getTeamRelatedPrices().then((prices) => prices.map((price) => price.id));
};
@@ -2,13 +2,13 @@ import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { type Subscription, type Team, type User } from '@documenso/prisma/client';
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
import { getCommunityPlanPriceIds } from './get-community-plan-prices';
import { getTeamPrices } from './get-team-prices';
import { getTeamRelatedPriceIds } from './get-team-related-prices';
type TransferStripeSubscriptionOptions = {
/**
@@ -46,14 +46,14 @@ export const transferTeamSubscription = async ({
throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
}
const [communityPlanIds, teamSeatPrices] = await Promise.all([
getCommunityPlanPriceIds(),
const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([
getTeamRelatedPriceIds(),
getTeamPrices(),
]);
const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan(
const teamSubscriptionRequired = !subscriptionsContainsActivePlan(
user.Subscription,
communityPlanIds,
teamRelatedPlanPriceIds,
);
let teamSubscription: Stripe.Subscription | null = null;
+2 -2
View File
@@ -35,14 +35,14 @@
"@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6",
"nodemailer": "^6.9.3",
"nodemailer": "^6.9.9",
"react-email": "^1.9.5",
"resend": "^2.0.0"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.8",
"@types/nodemailer": "^6.4.14",
"tsup": "^7.1.0"
}
}
@@ -1,3 +1,5 @@
import { env } from 'next-runtime-env';
import { Button, Column, Img, Link, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
@@ -10,7 +12,9 @@ export const TemplateDocumentSelfSigned = ({
documentName,
assetBaseUrl,
}: TemplateDocumentSelfSignedProps) => {
const signUpUrl = `${process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
const signUpUrl = `${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
@@ -1,3 +1,5 @@
import { env } from 'next-runtime-env';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
@@ -8,6 +10,8 @@ export interface TemplateResetPasswordProps {
}
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@@ -24,7 +28,7 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
<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={`${process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
href={`${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
>
Sign In
</Button>
@@ -0,0 +1,13 @@
import type { EffectCallback } from 'react';
import { useEffect } from 'react';
/**
* Dangerously runs an effect "once" by ignoring the depedencies of a given effect.
*
* DANGER: The effect will run twice in concurrent react and development environments.
*/
export const unsafe_useEffectOnce = (callback: EffectCallback) => {
// Intentionally avoiding exhaustive deps and rule of hooks here
// eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/rules-of-hooks
return useEffect(callback, []);
};
+12 -9
View File
@@ -1,16 +1,19 @@
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
export const IS_BILLING_ENABLED = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
import { env } from 'next-runtime-env';
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;
export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL');
export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL');
export const APP_BASE_URL = IS_APP_WEB
? process.env.NEXT_PUBLIC_WEBAPP_URL
: process.env.NEXT_PUBLIC_MARKETING_URL;
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
export const WEBAPP_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000';
export const APP_FOLDER = () => (IS_APP_MARKETING ? 'marketing' : 'web');
export const MARKETING_BASE_URL = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001';
export const APP_BASE_URL = () =>
IS_APP_WEB ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PUBLIC_MARKETING_URL();
export const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000';
export const MARKETING_BASE_URL = NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001';
+1 -2
View File
@@ -6,6 +6,5 @@ export enum STRIPE_CUSTOMER_TYPE {
export enum STRIPE_PLAN_TYPE {
TEAM = 'team',
COMMUNITY = 'community',
ENTERPRISE = 'enterprise',
}
export const TEAM_BILLING_DOMAIN = 'billing.team.documenso.com';
+8 -4
View File
@@ -1,5 +1,10 @@
import { env } from 'next-runtime-env';
import { APP_BASE_URL } from './app';
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
/**
* The flag name for global session recording feature flag.
*/
@@ -16,8 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account.
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
app_teams: true,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
marketing_header_single_player_mode: false,
} as const;
@@ -25,8 +29,8 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
* Extract the PostHog configuration from the environment.
*/
export function extractPostHogConfig(): { key: string; host: string } | null {
const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const postHogHost = `${APP_BASE_URL}/ingest`;
const postHogKey = NEXT_PUBLIC_POSTHOG_KEY();
const postHogHost = `${APP_BASE_URL()}/ingest`;
if (!postHogKey || !postHogHost) {
return null;
+1 -1
View File
@@ -6,4 +6,4 @@ export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20;
export const CAVEAT_FONT_PATH = `${APP_BASE_URL}/fonts/caveat.ttf`;
export const CAVEAT_FONT_PATH = () => `${APP_BASE_URL()}/fonts/caveat.ttf`;
@@ -24,3 +24,9 @@ export const RECIPIENT_ROLES_DESCRIPTION: {
roleName: 'Viewer',
},
};
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
} as const;
+1
View File
@@ -1,6 +1,7 @@
import { TeamMemberRole } from '@documenso/prisma/client';
export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+$');
export const TEAM_URL_REGEX = new RegExp('^/t/[^/]+');
export const TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, string> = {
ADMIN: 'Admin',
+2
View File
@@ -0,0 +1,2 @@
export const URL_REGEX =
/^(https?):\/\/(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i;
+20 -1
View File
@@ -7,13 +7,16 @@ import type { JWT } from 'next-auth/jwt';
import CredentialsProvider from 'next-auth/providers/credentials';
import type { GoogleProfile } from 'next-auth/providers/google';
import GoogleProvider from 'next-auth/providers/google';
import { env } from 'next-runtime-env';
import { prisma } from '@documenso/prisma';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { ErrorCode } from './error-codes';
@@ -90,6 +93,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}
}
if (!user.emailVerified) {
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
userId: user.id,
});
if (
!mostRecentToken ||
mostRecentToken.expires.valueOf() <= Date.now() ||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
await sendConfirmationToken({ email });
}
throw new Error(ErrorCode.UNVERIFIED_EMAIL);
}
return {
id: Number(user.id),
email: user.email,
@@ -203,7 +222,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
async signIn({ user }) {
// We do this to stop OAuth providers from creating an account
// when signups are disabled
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
const userData = await getUserByEmail({ email: user.email! });
return !!userData;
+1
View File
@@ -19,4 +19,5 @@ export const ErrorCode = {
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL',
} as const;
+1 -1
View File
@@ -43,7 +43,7 @@ export const setupTwoFactorAuthentication = async ({
const secret = crypto.randomBytes(10);
const backupCodes = new Array(10)
const backupCodes = Array.from({ length: 10 })
.fill(null)
.map(() => crypto.randomBytes(5).toString('hex'))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
@@ -5,11 +5,16 @@ import { render } from '@documenso/email/render';
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendConfirmationEmailProps {
userId: number;
}
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
const NEXT_PRIVATE_SMTP_FROM_NAME = process.env.NEXT_PRIVATE_SMTP_FROM_NAME;
const NEXT_PRIVATE_SMTP_FROM_ADDRESS = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS;
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@@ -30,10 +35,10 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
throw new Error('Verification token not found for the user');
}
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
const senderName = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAdress = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAdress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
assetBaseUrl,
@@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendForgotPasswordOptions {
userId: number;
}
@@ -29,8 +31,8 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
}
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 assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const resetPasswordLink = `${NEXT_PUBLIC_WEBAPP_URL()}/reset-password/${token}`;
const template = createElement(ForgotPasswordTemplate, {
assetBaseUrl,
@@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendResetPasswordOptions {
userId: number;
}
@@ -16,7 +18,7 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
},
});
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(ResetPasswordTemplate, {
assetBaseUrl,

Some files were not shown because too many files have changed in this diff Show More