Compare commits

..

91 Commits

Author SHA1 Message Date
48eeb40940 chore: fixed conflicts
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-01 13:03:48 +05:30
8d0e815b0f feat: add two factor auth (#643)
Add two factor authentication for users who wish to enhance the security of their accounts.
2023-12-01 11:22:16 +11:00
65cc26fd02 feat: email verification for registration (#599) 2023-11-21 15:42:29 +11:00
9b9b8a85e7 feat: add dark mode toggle (#529) 2023-11-17 16:58:27 +11:00
51938293f1 feat: enable resend email menu (#496) 2023-11-16 13:05:45 +11:00
34527c1842 fix: fetch the correct number of open issues using the github search api (#495) 2023-11-15 18:16:43 +11:00
e3b589516b feat: cache getServerComponentSession calls (#644) 2023-11-14 20:37:39 +11:00
1dd3a398d8 fix: safari pdf overflow issue fixed (#466) 2023-11-14 20:24:33 +11:00
587433fcd5 feat: limit document upload size (#347) 2023-11-14 20:07:55 +11:00
c2d7954467 chore: add some eslint rules (#344) 2023-11-14 20:01:45 +11:00
1c3cdf3165 feat: add dialog to confirm signing (#342) 2023-11-14 16:56:43 +11:00
d2a6cbd681 feat: use nextjs.js standalone output for improvised docker image (#338) 2023-11-10 17:41:49 +11:00
dc3d85f1a2 feat: add command menu and keyboard shortcuts (#337) 2023-11-08 22:36:12 +11:00
b4f4f4fc72 feat: duplicate document (#633) 2023-11-08 20:25:44 +11:00
24440c5cc4 Single Player Mode on Product Hunt
🚨 We are live on Product Hunt with Single Player Mode and the new free tier: [https://www.producthunt.com/products/documenso](https://www.producthunt.com/posts/documenso-singleplayer-mode)
2023-11-06 10:20:35 +01:00
c20397206a fix: typo in README.md (#630) 2023-11-06 12:18:24 +11:00
d686dcfa90 chore: include total and new user charts 2023-11-06 11:57:38 +11:00
dcee4eff58 fix: add white background for og images 2023-11-05 12:49:37 +11:00
48d7afe9f4 fix: add white background for og images 2023-11-05 12:49:04 +11:00
3009088b0e feat: show monthly new users 2023-11-05 12:48:05 +11:00
db01b1afdd fix: correctly sign SPM documents (#627)
- Sign and email correct SPM document
- Optimise signing SPM documents
2023-11-04 14:39:33 +11:00
53a323ccf8 fix: dont use custom documentData for single player mode 2023-11-04 13:27:09 +11:00
76c8dfa907 fix: updates from error logs 2023-11-04 13:27:09 +11:00
16af21ba25 fix: hiding of action buttons (#460)
* chore: fix hiding of action buttons
2023-11-03 16:39:43 +11:00
57ad16cc10 feat: add completed at timestamp 2023-11-03 15:48:40 +11:00
edfc67c050 fix: small typo error (#584) 2023-11-03 12:58:09 +11:00
1f953457db fix: days filter working (#623) 2023-11-03 12:34:11 +11:00
b5ec13eba1 chore: update bug report template (#580) 2023-11-03 11:45:15 +11:00
bf87800fbf Update README.md 2023-11-02 21:20:49 +01:00
f078dd2f6c fix: added the share btn in the UI and prewarm fetch (#615) 2023-11-02 13:38:34 +11:00
1e4f1ad4ae fix: seal document for single player mode (#617) 2023-11-02 12:47:19 +11:00
8ba5b8ac83 Merge branch 'feat/refresh' into date-format-setting 2023-11-01 14:45:10 +02:00
5bae42cf0f feat: improving incorrect default signature behaviour #594 (#600) 2023-11-01 19:39:21 +11:00
ee003e45b4 fix: improve the early adopters plan input section (#609) 2023-11-01 09:54:38 +11:00
2f718edf9b docs: re-add launch week 5 blogpost (#614) 2023-11-01 09:51:01 +11:00
09b956825f fix: typos and grammatical errors in readme (#616) 2023-11-01 09:50:35 +11:00
b576d6cede chore:  Improved the issue templates with issue forms (supported viayml ) (#612)
GitHub supports the issue forms [1].
[1] https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository#creating-issue-forms

This feature allows users to create issues using a structured and visually appealing form. This functionality is facilitated through a  file for the issue template.

Added the issue template for bug report, feature request, and improvement by adding respective  file. Also, removed previous  file which is no longer needed
2023-10-31 08:54:51 +02:00
dbff10e3de fix: page-not-found at share page for document signed in single-player-mode #605 (#606) 2023-10-31 08:54:16 +02:00
34e707b925 fix: invalid url with cloudfront 2023-10-31 12:50:47 +11:00
dba4c31ce3 feat: support cloudfront presign 2023-10-31 12:19:16 +11:00
9f5ee0d9ac feat: add database indexes 2023-10-30 16:58:51 +11:00
64d6923e05 chore: remove malfunction mania and feat/refresh from readme 2023-10-30 16:53:14 +11:00
3c6196f49c feat: add user schema timestamps 2023-10-28 20:57:26 +11:00
7ee9cb3f93 fix(web): fix typo + refactor empty state messages (#583)
* fix(web): fix typo + refactor empty state messages

* fix(web): refactor default empty-state message
2023-10-27 13:05:10 +03:00
f05cc2e652 Merge pull request #591 from olivierlambert/feat/refresh
feat: update the README for the self-hosting users
2023-10-27 15:59:22 +11:00
a047e230b9 Merge pull request #595 from adithyaakrishna/feat/slack2Discord
chore: updated slack to discord links
2023-10-27 15:58:24 +11:00
c98a860140 fix: add back slack support 2023-10-27 15:57:31 +11:00
f7a64eb97b Merge pull request #598 from documenso/chore/next-14
chore: upgrade to next 14.0.0
2023-10-27 15:52:56 +11:00
dc5a1819f1 chore: upgrade to next 14.0.0 2023-10-27 15:14:04 +11:00
b75e50065e chore: updated slack to discord links
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2023-10-26 11:37:10 +05:30
ef8fcd4ea9 fix: missing content updates 2023-10-26 16:58:42 +11:00
bf3747cd7b fix: change sign in links 2023-10-26 16:40:25 +11:00
3ff8a99250 fix: update early adopter error log 2023-10-26 14:26:10 +11:00
fb64496c7d fix: update stripe metadata for early adopters 2023-10-26 13:21:31 +11:00
407fa0047c fix: update limits handler 2023-10-26 12:26:29 +11:00
a2902ee7c0 fix: attach document to completed email 2023-10-25 22:34:51 +11:00
7599b84833 fix: limit recipients 2023-10-25 22:31:31 +11:00
769f543be1 fix: improve claim plan flow 2023-10-25 22:29:51 +11:00
d6447ffa82 fix: return response for failed invoices 2023-10-25 13:29:00 +11:00
f29ac73085 fix: update customer handling for checkouts 2023-10-25 13:13:57 +11:00
cb61898764 feat: update the README for the self-hosting users
Signed-off-by: Olivier Lambert <olivier.lambert@vates.fr>
2023-10-23 15:44:28 +02:00
d9161985ad check if date field is in use 2023-10-22 17:06:53 +01:00
fd8f6da2c6 fix: update singleplayer add signature to card 2023-10-22 12:40:09 +11:00
0b50176178 fix: limits no longer cache during session changes 2023-10-22 11:18:00 +11:00
c02640e104 fix: optimise pdf viewer rerendering 2023-10-21 13:08:29 +11:00
091960f269 fix: unbreak pdf viewer 2023-10-20 20:14:10 +11:00
594f3ec16a fix: move getFile to client side 2023-10-20 13:42:10 +11:00
a89da06842 fix: update webhook handler 2023-10-20 12:14:39 +11:00
db1d11309f fix: update webhook handler 2023-10-20 12:05:18 +11:00
616cf1c287 chore: update zod to 3.22.4 (#563)
* chore: updated zod 

Signed-off-by: Adithya Krishna <aadithya794@gmail.com>

---------

Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2023-10-19 21:24:05 +11:00
ba665edb1e fix: UI fixes and improvements (#559)
* chore: add cursor pointer

* fix: tooltip color

* chore: add tooltip

* fix: admin pages in mobile
2023-10-19 21:00:36 +11:00
1c34eddd10 chore: add prisma studio command (#576)
Co-authored-by: pit <pit@pits-MacBook-Pro.local>
2023-10-19 09:19:46 +03:00
1fbf6ed4ba fix: add mode to checkout session 2023-10-19 12:27:01 +11:00
1d6f7f9e37 fix(bug): name field can be updated with spaces #555 (#558) 2023-10-19 12:12:56 +11:00
e3c3ec7825 fix: downgrade react-pdf 2023-10-18 23:51:16 +11:00
08d176c803 Merge pull request #575 from 18feb06/fix-error-invalid-url-on-main-page-on-self-host-#573
fix: error invalid url on main page on self host #573
2023-10-18 23:20:22 +11:00
b952ed9035 fix: support multi env 2023-10-18 23:19:29 +11:00
43062dda12 chore: upgrade to latest next.js version (#553)
* chore: upgrade next.js
* fix: canvas not found error
* chore: upgrade package for marketing
* feat: add isServer conditional
* fix: inverse isServer condition
* fix: normalize packages
* fix: upgrade ee package
* fix: depdency nightmares
* fix: failing seed script
2023-10-18 22:33:02 +11:00
1b53ff9c2d Merge branch 'documenso:feat/refresh' into fix-error-invalid-url-on-main-page-on-self-host-#573 2023-10-18 16:30:57 +05:00
acd3e6d613 fix: invalid url on main page in self host 2023-10-18 16:28:57 +05:00
e33b02df56 fix: truncate long file name in admin dashboard (#572)
Co-authored-by: pit <pit@192-168-0-136.rdsnet.ro>
2023-10-18 08:58:35 +03:00
2c6849ca76 fix: email requesting signature shows "completed document" in preview… (#514) 2023-10-18 15:32:39 +11:00
7afe8cfa18 constant files created 2023-10-17 19:12:45 +01:00
9434f9e2e4 fix: support mailto link fix (#571) 2023-10-17 17:27:02 +11:00
f6daef7333 Merge pull request #566 from documenso/feat/plan-limits
feat: plan limits
2023-10-17 14:30:18 +11:00
c3df8d4c2a fix: add redirects for v0.9 requests 2023-10-17 13:50:54 +11:00
4b09693862 feat: add safari clipboard copy support (#486) 2023-10-17 12:40:36 +11:00
8d2e50d1fe fix: user avatar on admin documents table (#570)
Co-authored-by: pit <pit@pits-MacBook-Pro.local>
2023-10-16 17:36:12 +03:00
bfc749f30b fix: fix for Accepting signatures or text fields with white space only #551 (#557) 2023-10-16 20:08:45 +11:00
e0d4255700 fix: enable dragging fields (#565) 2023-10-16 19:50:28 +11:00
9139f3a3b4 date format select added 2023-10-03 14:39:00 +01:00
206 changed files with 9779 additions and 5730 deletions

View File

@ -2,6 +2,11 @@
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="secret"
# [[CRYPTO]]
# Application Key for symmetric encryption and decryption
# This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
# [[AUTH OPTIONAL]]
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""

View File

@ -1,50 +0,0 @@
---
name: Bug Report
about: Create a bug report to help us improve
---
<!--- Please provide a general summary of the issue in the Title above -->
## Issue Description
<!--- Please provide a clear and concise description of the problem. -->
## Steps to Reproduce
<!--- Please provide step-by-step instructions to reproduce the issue. -->
<!--- Include code snippets, error messages, and any other relevant information. -->
1. Step 1
2. Step 2
3. ...
## Expected Behavior
<!--- Describe what you expected to happen. -->
## Current Behavior
<!--- Describe what is currently happening. -->
## Screenshots (optional)
<!--- If applicable, add screenshots to help explain the issue. -->
## Environment
<!--- Please provide information about your environment, such as operating system, browser, version, etc. -->
- OS: [e.g., Windows 10]
- Browser: [e.g., Chrome, Firefox]
- Version: [e.g., 2.0.1]
## Checklist
<!--- Please check the boxes that apply to this issue report. -->
<!--- You can add or remove items as needed. -->
- [ ] I have searched the existing issues to make sure this is not a duplicate.
- [ ] I have provided steps to reproduce the issue.
- [ ] I have included relevant environment information.
- [ ] I have included any relevant screenshots.
- [ ] I understand that this is a voluntary contribution and that there is no guarantee of resolution.

48
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@ -0,0 +1,48 @@
name: "Bug Report"
labels: "bug"
description: Create a bug report to help us improve
body:
- type: markdown
attributes:
value:
Thank you for reporting an issue.
Please fill in as much of the form below as you're able to.
- type: textarea
attributes:
label: Issue Description
description: Please provide a clear and concise description of the problem.
- type: textarea
attributes:
label: Steps to Reproduce
description: Please provide step-by-step instructions to reproduce the issue. Include code snippets, error messages, and any other relevant information.
- type: textarea
attributes:
label: Expected Behavior
description: Describe what you expected to happen.
- type: textarea
attributes:
label: Current Behavior
description: Describe what is currently happening.
- type: textarea
attributes:
label: Screenshots (optional)
description: If applicable, add screenshots to help explain the issue.
- type: input
attributes:
label: Operating System [e.g., Windows 10]
- type: input
attributes:
label: Browser [e.g., Chrome, Firefox]
- type: input
attributes:
label: Version [e.g., 2.0.1]
- type: checkboxes
attributes:
label: Please check the boxes that apply to this issue report.
options:
- label: I have searched the existing issues to make sure this is not a duplicate.
- label: I have provided steps to reproduce the issue.
- label: I have included relevant environment information.
- label: I have included any relevant screenshots.
- label: I understand that this is a voluntary contribution and that there is no guarantee of resolution.
- label: I want to work on creating a PR for this issue if approved

View File

@ -1,41 +0,0 @@
---
name: Feature Request
about: Suggest a new idea or enhancement for this project
---
<!--- Please provide a clear and concise title for your feature request -->
## Feature Description
<!--- Describe the feature you are requesting in detail. -->
<!--- Explain what problem it solves or what value it adds to the project. -->
## Use Case
<!--- Provide a scenario or use case where this feature would be beneficial. -->
<!--- Explain how users would interact with this feature and why it's important. -->
## Proposed Solution
<!--- If you have an idea of how this feature could be implemented, describe it here. -->
<!--- Include any technical details, UI/UX considerations, or design suggestions. -->
## Alternatives (optional)
<!--- Are there any alternative ways to achieve the same goal? -->
<!--- Describe other approaches that could be considered if this feature is not implemented. -->
## Additional Context
<!--- Add any additional context or information that might be relevant to the feature request. -->
## Checklist
<!--- Please check the boxes that apply to this feature request. -->
<!--- You can add or remove items as needed. -->
- [ ] I have searched the existing feature requests to make sure this is not a duplicate.
- [ ] I have provided a detailed description of the requested feature.
- [ ] I have explained the use case or scenario for this feature.
- [ ] I have included any relevant technical details or design suggestions.
- [ ] I understand that this is a suggestion and that there is no guarantee of implementation.

View File

@ -0,0 +1,35 @@
name: "Feature Request"
description: Suggest a new idea or enhancement for this project
body:
- type: markdown
attributes:
value: Please provide a clear and concise title for your feature request
- type: textarea
attributes:
label: Feature Description
description: Describe the feature you are requesting in detail. Explain what problem it solves or what value it adds to the project.
- type: textarea
attributes:
label: Use Case
description: Provide a scenario or use case where this feature would be beneficial. Explain how users would interact with this feature and why it's important.
- type: textarea
attributes:
label: Proposed Solution
description: If you have an idea of how this feature could be implemented, describe it here. Include any technical details, UI/UX considerations, or design suggestions.
- type: textarea
attributes:
label: Alternatives (optional)
description: Are there any alternative ways to achieve the same goal? Describe other approaches that could be considered if this feature is not implemented.
- type: textarea
attributes:
label: Additional Context
description: Add any additional context or information that might be relevant to the feature request.
- type: checkboxes
attributes:
label: Please check the boxes that apply to this feature request.
options:
- label: I have searched the existing feature requests to make sure this is not a duplicate.
- label: I have provided a detailed description of the requested feature.
- label: I have explained the use case or scenario for this feature.
- label: I have included any relevant technical details or design suggestions.
- label: I understand that this is a suggestion and that there is no guarantee of implementation.

View File

@ -1,41 +0,0 @@
---
name: General Improvement
about: Suggest a minor enhancement or improvement for this project
---
<!--- Please provide a clear and concise title for your improvement suggestion -->
## Improvement Description
<!--- Describe the improvement you are suggesting in detail. -->
<!--- Explain what specific aspect of the project it addresses or enhances. -->
## Rationale
<!--- Explain why this improvement would be beneficial. -->
<!--- Share any context, pain points, or reasons for suggesting this change. -->
## Proposed Solution
<!--- If you have a suggestion for how this improvement could be implemented, describe it here. -->
<!--- Include any technical details, design suggestions, or other relevant information. -->
## Alternatives (optional)
<!--- Are there any alternative approaches to achieve the same improvement? -->
<!--- Describe other ways to address the issue or enhance the project. -->
## Additional Context
<!--- Add any additional context or information that might be relevant to the improvement suggestion. -->
## Checklist
<!--- Please check the boxes that apply to this improvement suggestion. -->
<!--- You can add or remove items as needed. -->
- [ ] I have searched the existing issues and improvement suggestions to avoid duplication.
- [ ] I have provided a clear description of the improvement being suggested.
- [ ] I have explained the rationale behind this improvement.
- [ ] I have included any relevant technical details or design suggestions.
- [ ] I understand that this is a suggestion and that there is no guarantee of implementation.

35
.github/ISSUE_TEMPLATE/improvement.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: "General Improvement"
description: Suggest a minor enhancement or improvement for this project
body:
- type: markdown
attributes:
value: Please provide a clear and concise title for your improvement suggestion
- type: textarea
attributes:
label: Improvement Description
description: Describe the improvement you are suggesting in detail. Explain what specific aspect of the project it addresses or enhances.
- type: textarea
attributes:
label: Rationale
description: Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change.
- type: textarea
attributes:
label: Proposed Solution
description: If you have a suggestion for how this improvement could be implemented, describe it here. Include any technical details, design suggestions, or other relevant information.
- type: textarea
attributes:
label: Alternatives (optional)
description: Are there any alternative approaches to achieve the same improvement? Describe other ways to address the issue or enhance the project.
- type: textarea
attributes:
label: Additional Context
description: Add any additional context or information that might be relevant to the improvement suggestion.
- type: checkboxes
attributes:
label: Please check the boxes that apply to this improvement suggestion.
options:
- label: I have searched the existing issues and improvement suggestions to avoid duplication.
- label: I have provided a clear description of the improvement being suggested.
- label: I have explained the rationale behind this improvement.
- label: I have included any relevant technical details or design suggestions.
- label: I understand that this is a suggestion and that there is no guarantee of implementation.

1
.gitignore vendored
View File

@ -31,6 +31,7 @@ yarn-error.log*
# turbo
.turbo
.turbo-cookie
# vercel
.vercel

16
.prettierignore Normal file
View File

@ -0,0 +1,16 @@
node_modules
.next
public
**/**/node_modules
**/**/.next
**/**/public
*.lock
*.log
*.test.ts
.gitignore
.npmignore
.prettierignore
.DS_Store
.eslintignore

125
README.md
View File

@ -1,3 +1,5 @@
🚨 We are live on Product Hunt with Single Player Mode and the new free tier: [https://www.producthunt.com/products/documenso](https://www.producthunt.com/posts/documenso-singleplayer-mode)
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
<p align="center" style="margin-top: 20px">
@ -30,14 +32,6 @@
<a href="code_of_conduct.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="Contributor Covenant"></a>
</p>
> 🦺 Documenso 1.0 is deployed to our <a href="https://documen.so/staging" target="_blank">Staging Environment</a>.
>
> The code can be found on the [feat/refresh](https://github.com/documenso/documenso/tree/feat/refresh) branch.
>
> The new version will be released after the current testing phase.
# Join us in testing Documenso 1.0 during [MALFUNCTION MANIA](https://documenso.com/blog/malfunction-mania)
<div>
<img style="display: block; height: 120px; width: 24%"
src="https://github.com/documenso/documenso/assets/1309312/67e08c98-c153-4115-aa2d-77979bb12c94)">
@ -70,14 +64,14 @@ Join us in creating the next generation of open trust infrastructure.
## Community and Next Steps 🎯
We're currently working on a redesign of the application, including a revamp of the codebase so Documenso can be more intuitive to use and robust to develop upon.
We're currently working on a redesign of the application, including a revamp of the codebase, so Documenso can be more intuitive to use and robust to develop upon.
- Check out the first source code release in this repository and test it
- Tell us what you think in the [Discussions](https://github.com/documenso/documenso/discussions)
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members
- ⭐ the repository to help us raise awareness
- Spread the word on Twitter that Documenso is working towards a more open signing tool
- Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release
- Check out the first source code release in this repository and test it.
- Tell us what you think in the [Discussions](https://github.com/documenso/documenso/discussions).
- Join the [Discord server](https://documen.so/discord) for any questions and getting to know to other community members.
- ⭐ the repository to help us raise awareness.
- Spread the word on Twitter that Documenso is working towards a more open signing tool.
- Fix or create [issues](https://github.com/documenso/documenso/issues), that are needed for the first production release.
## Contributing
@ -111,7 +105,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
### Requirements
To run Documenso locally you will need
To run Documenso locally, you will need
- Node.js
- Postgres SQL Database
@ -129,7 +123,7 @@ Want to get up and running quickly? Follow these steps:
git clone https://github.com/documenso/documenso
```
2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively just run `cp .env.example .env` to get started with our handpicked defaults.
2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively, just run `cp .env.example .env` to get started with our handpicked defaults.
3. Run `npm run dx` in the root directory
@ -156,7 +150,7 @@ npm run d
### Manual Setup
Follow these steps to setup documenso on you local machine:
Follow these steps to setup Documenso on your local machine:
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
@ -164,11 +158,11 @@ Follow these steps to setup documenso on you local machine:
git clone https://github.com/documenso/documenso
```
2. Run `npm i` in root directory
2. Run `npm i` in the root directory
3. Create your `.env` from the `.env.example`. You can use `cp .env.example .env` to get started with our handpicked defaults.
4. Set the following environement variables.
4. Set the following environment variables:
- NEXTAUTH_URL
- NEXTAUTH_SECRET
@ -181,15 +175,15 @@ git clone https://github.com/documenso/documenso
5. Create the database schema by running `npm run prisma:migrate-dev`
6. Run `npm run dev` root directory to start
6. Run `npm run dev` in the root directory to start
7. Register a new user at http://localhost:3000/signup
---
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document
- Optional: Create your own signing certificate
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL) see **[Create your own signing certificate](./SIGNING.md)**.
- Optional: Seed the database using `npm run prisma:seed -w @documenso/prisma` to create a test user and document.
- Optional: Create your own signing certificate.
- To generate your own using these steps and a Linux Terminal or Windows Subsystem for Linux (WSL), see **[Create your own signing certificate](./SIGNING.md)**.
### Run in Gitpod
@ -203,13 +197,80 @@ We support DevContainers for VSCode. [Click here to get started.](https://vscode
## Docker
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple docker build and publish pipeline for Documenso.
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso.
## Self Hosting
We support a variety of deployment methods, and are actively working on adding more. Stay tuned for updates!
> Please note the below deployment methods are for v0.9, we will update these to v1.0 once it has been released.
> Please note that the below deployment methods are for v0.9, we will update these to v1.0 once it has been released.
### Fetch, configure, and build
First, clone the code from Github:
```
git clone https://github.com/documenso/documenso.git
```
Then, inside the `documenso` folder, copy the example env file:
```
cp .env.example .env
```
The following environment variables must be set:
* `NEXTAUTH_URL`
* `NEXTAUTH_SECRET`
* `NEXT_PUBLIC_WEBAPP_URL`
* `NEXT_PUBLIC_MARKETING_URL`
* `NEXT_PRIVATE_DATABASE_URL`
* `NEXT_PRIVATE_DIRECT_DATABASE_URL`
* `NEXT_PRIVATE_SMTP_FROM_NAME`
* `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for both `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables!
Now you can install the dependencies and build it:
```
npm i
npm run:build:web
npm run prisma:migrate-deploy
```
Finally, you can start it with:
```
npm run start
```
This will start the server on `localhost:3000`. For now, any reverse proxy can then do the frontend and SSL termination.
> If you want to run with another port than 3000, you can start the application with `next -p <ANY PORT>` from the `apps/web` folder.
### Run as a service
You can use a systemd service file to run the app. Here is a simple example of the service running on port 3500 (using 3000 by default):
```bash
[Unit]
Description=documenso
After=network.target
[Service]
Environment=PATH=/path/to/your/node/binaries
Type=simple
User=www-data
WorkingDirectory=/var/www/documenso/apps/web
ExecStart=/usr/bin/next start -p 3500
TimeoutSec=15
Restart=always
[Install]
WantedBy=multi-user.target
```
### Railway
@ -221,15 +282,15 @@ We support a variety of deployment methods, and are actively working on adding m
## Troubleshooting
### I'm not receiving any emails when using the developer quickstart
### I'm not receiving any emails when using the developer quickstart.
When using the developer quickstart an [Inbucket](https://inbucket.org/) server will be spun up in a docker container that will store all outgoing email locally for you to view.
When using the developer quickstart, an [Inbucket](https://inbucket.org/) server will be spun up in a docker container that will store all outgoing emails locally for you to view.
The Web UI can be found at http://localhost:9000 while the SMTP port will be on localhost:2500.
The Web UI can be found at http://localhost:9000, while the SMTP port will be on localhost:2500.
### Support IPv6
In case you are deploying to a cluster that uses only IPv6. You can use a custom command to pass a parameter to the Next.js start command
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Next.js start command
For local docker run
@ -254,7 +315,7 @@ containers:
- '::'
```
### I can't see environment variables in my package scripts
### I can't see environment variables in my package scripts.
Wrap your package script with the `with:env` script like such:
@ -262,7 +323,7 @@ Wrap your package script with the `with:env` script like such:
npm run with:env -- npm run myscript
```
The same can be done when using `npx` for one of bin scripts:
The same can be done when using `npx` for one of the bin scripts:
```
npm run with:env -- npx myscript

View File

@ -1,6 +1,6 @@
---
title: 'Building Documenso — Part 1: Certificates'
description: This is the first part of the new Building Documenso series, where I describe the challenges and design choices that we make while building the worlds most open signing platform.
description: In today's fast-paced world, productivity and efficiency are crucial for success, both in personal and professional endeavors. We all strive to make the most of our time and energy to achieve our goals effectively. However, it's not always easy to stay on track and maintain peak performance. In this blog post, we'll explore 10 valuable tips to help you boost productivity and efficiency in your daily life.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'

View File

@ -0,0 +1,49 @@
---
title: Open Sourcing Documenso's Design
description: It's day 1 of our first launch week. We are kicking it off by open sourcing Documenso's design system! Let's go..
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2023-09-25
tags:
- Design
- Open Source
- Community
---
<figure>
<MdxNextImage
src="/blog/designsystem.png"
width="1260"
height="630"
alt="Documenso's Design System"
/>
<figcaption className="text-center">
Documenso's Design System ✨
</figcaption>
</figure>
> TLDR; Our design system is OSS under MIT at [design.documenso.com](https://design.documenso.com)
Today, we are open sourcing our design system, lovingly crafted by [Thilo](https://twitter.com/thilokonzok). The system is public on Figma and will be available at [design.documenso.com](https://design.documenso.com) from here on out.
We are publishing it under the MIT License so everyone can share, remix, and use it as it helps them most.
We chose to start our first launch week with a design topic to emphasize the role design will play in Documenso's company and community culture. As it is historically difficult to bring together open-source software with great design, this is our first step towards encouraging a more design-driven COSS (Commercial Open Source) movement.
## Designers Welcome
We added a designer role in our Discord to create a space for designers to explore and discuss design-related topics of Documenso and signing in general. In the future, we want to foster more coding contributions and start a design culture around the product. As it is much more difficult to incorporate design contributions, we have yet to find a clear plan of what that will look like. I would like to see contributions around stuff we are NOT working on. Designs in that area can inspire and start discussions without the complexities of implementing them immediately — a free-thinking space around everything Documenso. Having a free mandate to design without restriction can create many exciting ideas. Some Ideas worth exploring:
## Areas for design contributions
- Explorations of exciting aspects of signing and document handling:
- What does signing look like when we no longer have skeuomorphic signatures?
- What is signing if we move beyond paper-inspired documents?
- What would the government process look like using Documenso?
- Solutions Concepts for features further down the roadmap, e.g., Widgets in websites
These are fascinating ideas for explorative design. They won't be built 1:1 but shape how we think about signing and where it can go, which is even more critical. If you are interested in product design, you are invited to join our [Discord](https://documen.so/discord) and discuss the future of signing design or the future of Documenso's design system. Also let me know what you think on [X (formerly Twitter)](https://x.com/eltimuro).
Best from Hamburg\
Timur

View File

@ -24,8 +24,6 @@ tags:
</figcaption>
</figure>
> TLDR; we have 100 early adopter accounts available at a great price. [Secure yours now](https://documenso.com/pricing).
## Community-Driven Development
As we ramp up hiring and development speed for Documenso, I want to discuss how we plan to build its core version.
@ -50,7 +48,7 @@ Documenso currently runs the community reviewed 0.9.1 version. Getting from here
## Extending our open metrics
As part of our ongoing effort to be open and transparent in our doing, we are adding "Early Adopters" to our [/open page](https://documenso.com/open) page. After we exceed the early adopter slots, this metric will transition to "Customers". When no more early adopter seats can be claimed, the early adopter plan will transition to a standard paid plan. It will still be priced at $30/mo., but will no longer include upcoming features or unlimited seats. You can [claim your early adopter account here](https://documenso.com/pricing).
As part of our ongoing effort to be open and transparent in our doing, we are adding "Early Adopters" to our [/open page](https://documenso.com/open) page. After we exceed the early adopter slots, this metric will transition to "Customers". When no more early adopter seats can be claimed, the early adopter plan will transition to a standard paid plan. It will still be priced at $30/mo., but will no longer include upcoming features or unlimited seats.
If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord).

View File

@ -0,0 +1,75 @@
---
title: The 🔴 LIVE Roadmap
description: It's the Launch Week Day finale, Day 5! We are going out with a bang and introducing the 🔴 LIVE roadmap, featuring our next Product Hunt Launch - Free Singleplayer Documenso - Sign without creating an account!
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2023-09-29
tags:
- Free Plan
- Launch
- Roadmap
---
<figure>
<MdxNextImage
src="/blog/roadmap.png"
width="1260"
height="630"
alt="XKCD: Bug"
/>
<figcaption className="text-center">
"Being early is, uh, good." -Unknown
</figcaption>
</figure>
> TLDR; We are launching a [🔴 LIVE roadmap](https://documen.so/launches), that gets updated regularly.\
> First upcoming launch: A free single signer experience.
## The Road Ahead
It's been quite a week, launching our [design system](https://documenso.com/blog/design-system), [kicking of a Malfunction Mania](https://documenso.com/blog/malfunction-mania), a [shiny new contributor perk](https://documenso.com/blog/shop) and our [early adopter deal](https://documenso.com/blog/early-adopters). After discussing Version 1.0 a lot, we want to close the week by looking ahead. As an open company, we want transparency about what we are working on and what to expect next. Especially for our Early Adopters, we want to paint a clear picture of what to expect in the near term.
Communicating software development progress and goals is historically tricky because of it's complex nature. Exact release dates are notoriously unreliable; if they are not, they force a particular style of development, forcing the team to make tradeoffs on scope and quality.
To give an appropriate insight into our work, we are launching our new [🔴 LIVE roadmap](https://documen.so/launches) today:
- A List of quarterly development and feature goals
- That gets updated regularly (bi-weekly)
- Has up-to-date insights from the team on where we stand
**While there are some great features planned, one stands out: Single Player Mode!**
## Announcing Documenso Singleplayer Mode
<a href="https://www.producthunt.com/products/documenso" target="_blank">
<figure>
<MdxNextImage
src="/blog/sp5.png"
width="1260"
height="630"
/>
<figcaption className="text-center font-bold">
🚨 We are going back to Product Hunt! Are you ready, player one?
</figcaption>
</figure>
</a>
Single Player Mode will be a free, loginless signing experience for single signers. If you hate creating an account to sign a single, once-in-a-while-document yourself as much as we do, we've got you. Our new free tier will accompany Singleplayer. While you don't HAVE to create an account to sign sth. quickly, you can. A free tier Documenso Account will give you the following:
- A place to store all your sent and received Documenso-signed documents
- Free forever
- Unlimited recipients
- 5 free signatures per month (for now, excluding third party types like QES)
- The use of templates, as soon as we [release](https://documen.so/launches) them
Singleplayer will launch in the first half of October, shortly after the release of 1.0, **[ON PRODUCT HUNT!](https://www.producthunt.com/products/documenso)** That's right, we are going back to Product Hunt to kick of this new phase of Documenso and you are invited to join us once again :)
**[Subscribe on PH](https://www.producthunt.com/products/documenso)** to be notified when we launch.
If you have any questions or comments, please reach out on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord).
Best from Hamburg\
Timur

View File

@ -16,7 +16,7 @@ tags:
src="/blog/mm.png"
width="1260"
height="630"
alt="Malfunctioning Documenso Logo inbroken colors"
alt="Malfunctioning Documenso Logo in broken colors"
/>
<figcaption className="text-center">
@ -49,9 +49,9 @@ As Documenso 1.0 just hit the staging environment, we're calling a MALFUNCTION M
- Fix bugs by creating a Pull Request (PR);
- Look over and add missing documentation/ Quickstarts and other useful resources.
We don't have a specific end date for Malfunction Mania. We plan to move the staging version into the production environment by the end of the month once we're happy with the results. Bug reports and fixes are, of course, always welcome going forward.
Best from Hamburg
Timur
We don't have a specific end date for Malfunction Mania. We plan to move the staging version into the production environment by the end of the month once we're happy with the results. Bug reports and fixes are, of course, always welcome going forward.
**[Follow Documenso on Twitter / X](https://documen.so/tw) and [join the Discord server](https://documen.so/discord) to get the latest about Malfunction Mania.**

View File

@ -12,7 +12,7 @@ tags:
Since we launched [Documenso 0.9 on Product Hunt](https://producthunt.com/products/documenso#documenso) last May, the team's been hard at work behind the scenes to ramp up development and design to deliver an excellent next version.
Last week, Lucas shared the reasoning on [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
Last week, Lucas shared the reasoning how [why we're doing a rewrite](https://documenso.com/blog/why-were-doing-a-rewrite).
Today, I'm pleased to share with you a preview of the next Documenso.

View File

@ -0,0 +1,42 @@
---
title: Design System
---
# We're building a beautiful, open-source alternative to DocuSign
> Read more about our design culture here:
>
> [https://documenso.com/blog/design-system](https://documenso.com/blog/design-system)
At Documenso, we aim to be a design-driven company.
We believe that design isn't just about how things look, but also how they work. We want to make sure that the product is easy to use and intuitive. We also want to ensure that the website, desktop and mobile apps are consistent and look and feel like they belong together.
To achieve this, we've created Documenso's design system containing tokens, primitives, and components, screens, and brand assets.
We're open-sourcing this design system so you can see how we build the product and think about design as a whole.
## Check out the design system
<iframe
src="https://documen.so/design-system-embed"
className="aspect-square w-full border-none"
frameBorder="0"
/>
## Remix and Share the community version on Figma
<a href="https://documen.so/design" target="_blank">
<figure>
<MdxNextImage
src="/blog/designsystem.png"
width="1260"
height="630"
alt="Documenso's Design System"
/>
<figcaption className="text-center">
Documenso's Design System ✨
</figcaption>
</figure>
</a>

View File

@ -2,14 +2,17 @@
const path = require('path');
const { withContentlayer } = require('next-contentlayer');
require('dotenv').config({
path: path.join(__dirname, '../../.env.local'),
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
ENV_FILES.forEach((file) => {
require('dotenv').config({
path: path.join(__dirname, `../../${file}`),
});
});
/** @type {import('next').NextConfig} */
const config = {
experimental: {
serverActions: true,
serverActionsBodySizeLimit: '10mb',
},
reactStrictMode: true,
@ -22,6 +25,14 @@ const config = {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
},
},
webpack: (config, { isServer }) => {
// fixes: Module not found: Cant resolve ../build/Release/canvas.node
if (isServer) {
config.resolve.alias.canvas = false;
}
return config;
},
async headers() {
return [
{

View File

@ -6,8 +6,9 @@
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"start": "next start -p 3001",
"lint": "next lint",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
@ -20,9 +21,10 @@
"contentlayer": "^0.3.4",
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "13.4.19",
"next-auth": "4.22.3",
"next": "14.0.0",
"next-auth": "4.24.3",
"next-contentlayer": "^0.3.4",
"next-plausible": "^3.10.1",
"perfect-freehand": "^1.2.0",
@ -34,8 +36,8 @@
"react-icons": "^4.11.0",
"recharts": "^2.7.2",
"sharp": "0.32.5",
"typescript": "5.1.6",
"zod": "^3.21.4"
"typescript": "5.2.2",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "20.1.0",

View File

@ -1,4 +1,4 @@
import { ImageResponse } from 'next/server';
import { ImageResponse } from 'next/og';
import { allBlogPosts } from 'contentlayer/generated';
@ -41,7 +41,7 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
return new ImageResponse(
(
<div tw="relative h-full w-full flex flex-col items-center justify-center text-center">
<div tw="relative h-full w-full flex flex-col items-center justify-center text-center bg-white">
{/* @ts-expect-error Lack of typing from ImageResponse */}
<img src={backgroundImage} alt="og-background" tw="absolute inset-0 w-full h-full" />

View File

@ -32,10 +32,21 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
}
const session = await stripe.checkout.sessions.retrieve(sessionId);
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id;
if (!customerId) {
redirect('/');
}
const customer = await stripe.customers.retrieve(customerId);
if (!customer || customer.deleted) {
redirect('/');
}
const user = await prisma.user.findFirst({
where: {
id: Number(session.client_reference_id),
id: Number(customer.metadata.userId),
},
});
@ -165,7 +176,7 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
</p>
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`}
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signin`}
target="_blank"
className="mt-4 block"
>

View File

@ -40,19 +40,28 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
<span>{extraInfo}</span>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border pr-2 shadow-sm hover:shadow">
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={chartHeight}>
<BarChart data={formattedData} margin={{ top: 30, right: 20 }}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
itemStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value) => [Number(value), label]}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar dataKey={metricKey as string} fill="hsl(var(--primary))" label={label} />{' '}
<Bar
dataKey={metricKey as string}
maxBarSize={60}
fill="hsl(var(--primary))"
label={label}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>

View File

@ -71,7 +71,7 @@ export const CapTable = ({ className, ...props }: CapTableProps) => {
</Pie>
<Legend
formatter={(value) => {
return <span className="text-sm text-black">{value}</span>;
return <span className="text-foreground text-sm">{value}</span>;
}}
/>
<Tooltip

View File

@ -21,7 +21,7 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
<div className={cn('flex flex-col', className)} {...props}>
<h3 className="px-4 text-lg font-semibold">Total Funding Raised</h3>
<div className="border-border mt-2.5 flex flex-1 flex-col items-center justify-center rounded-2xl border p-4 shadow-sm hover:shadow">
<div className="border-border mt-2.5 flex flex-1 flex-col items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}>
<XAxis dataKey="date" />
@ -35,6 +35,9 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
}
/>
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
itemStyle={{
color: 'hsl(var(--primary-foreground))',
}}
@ -48,7 +51,13 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
]}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar dataKey="amount" fill="hsl(var(--primary))" label="Amount Raised" />
<Bar
dataKey="amount"
fill="hsl(var(--primary))"
label="Amount Raised"
maxBarSize={60}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>

View File

@ -0,0 +1,51 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils';
export type MonthlyNewUsersChartProps = {
className?: string;
data: GetUserMonthlyGrowthResult;
};
export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartProps) => {
const formattedData = [...data].reverse().map(({ month, count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'),
count: Number(count),
};
});
return (
<div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4">
<h3 className="text-lg font-semibold">Monthly New Users</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
formatter={(value) => [Number(value).toLocaleString('en-US'), 'New Users']}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label="New Users"
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -0,0 +1,51 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils';
export type MonthlyTotalUsersChartProps = {
className?: string;
data: GetUserMonthlyGrowthResult;
};
export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersChartProps) => {
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'),
count: Number(count),
};
});
return (
<div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4">
<h3 className="text-lg font-semibold">Monthly Total Users</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
formatter={(value) => [Number(value).toLocaleString('en-US'), 'Total Users']}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label="Total Users"
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
import { MetricCard } from '~/app/(marketing)/open/metric-card';
import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
@ -7,11 +9,23 @@ import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
import { BarMetric } from './bar-metrics';
import { CapTable } from './cap-table';
import { FundingRaised } from './funding-raised';
import { MonthlyNewUsersChart } from './monthly-new-users-chart';
import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
import { TeamMembers } from './team-members';
import { OpenPageTooltip } from './tooltip';
export const revalidate = 3600;
export const dynamic = 'force-dynamic';
const GITHUB_HEADERS: Record<string, string> = {
accept: 'application/vnd.github.v3+json',
};
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
}
const ZGithubStatsResponse = z.object({
stargazers_count: z.number(),
forks_count: z.number(),
@ -22,6 +36,10 @@ const ZMergedPullRequestsResponse = z.object({
total_count: z.number(),
});
const ZOpenIssuesResponse = z.object({
total_count: z.number(),
});
const ZStargazersLiveResponse = z.record(
z.object({
stars: z.number(),
@ -42,45 +60,78 @@ const ZEarlyAdoptersResponse = z.record(
export type StargazersType = z.infer<typeof ZStargazersLiveResponse>;
export type EarlyAdoptersType = z.infer<typeof ZEarlyAdoptersResponse>;
export default async function OpenPage() {
const {
forks_count: forksCount,
open_issues: openIssues,
stargazers_count: stargazersCount,
} = await fetch('https://api.github.com/repos/documenso/documenso', {
const fetchGithubStats = async () => {
return await fetch('https://api.github.com/repos/documenso/documenso', {
headers: {
accept: 'application/vnd.github.v3+json',
...GITHUB_HEADERS,
},
})
.then(async (res) => res.json())
.then((res) => ZGithubStatsResponse.parse(res));
};
const { total_count: mergedPullRequests } = await fetch(
const fetchOpenIssues = async () => {
return await fetch(
'https://api.github.com/search/issues?q=repo:documenso/documenso+type:issue+state:open&page=0&per_page=1',
{
headers: {
...GITHUB_HEADERS,
},
},
)
.then(async (res) => res.json())
.then((res) => ZOpenIssuesResponse.parse(res));
};
const fetchMergedPullRequests = async () => {
return await fetch(
'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1',
{
headers: {
accept: 'application/vnd.github.v3+json',
...GITHUB_HEADERS,
},
},
)
.then(async (res) => res.json())
.then((res) => ZMergedPullRequestsResponse.parse(res));
};
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
const fetchStargazers = async () => {
return await fetch('https://stargrazer-live.onrender.com/api/stats', {
headers: {
accept: 'application/json',
},
})
.then(async (res) => res.json())
.then((res) => ZStargazersLiveResponse.parse(res));
};
const EARLY_ADOPTERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
const fetchEarlyAdopters = async () => {
return await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
headers: {
accept: 'application/json',
},
})
.then(async (res) => res.json())
.then((res) => ZEarlyAdoptersResponse.parse(res));
};
export default async function OpenPage() {
const [
{ forks_count: forksCount, stargazers_count: stargazersCount },
{ total_count: openIssues },
{ total_count: mergedPullRequests },
STARGAZERS_DATA,
EARLY_ADOPTERS_DATA,
] = await Promise.all([
fetchGithubStats(),
fetchOpenIssues(),
fetchMergedPullRequests(),
fetchStargazers(),
fetchEarlyAdopters(),
]);
const MONTHLY_USERS = await getUserMonthlyGrowth();
return (
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
@ -122,7 +173,7 @@ export default async function OpenPage() {
<TeamMembers className="col-span-12" />
<SalaryBands className="col-span-12 lg:col-span-6" />
<SalaryBands className="col-span-12" />
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
@ -172,6 +223,9 @@ export default async function OpenPage() {
className="col-span-12 lg:col-span-6"
/>
<MonthlyTotalUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
<h2 className="text-2xl font-bold">Where's the rest?</h2>

View File

@ -23,8 +23,8 @@ export function OpenPageTooltip() {
<path
d="M7.49991 0.876892C3.84222 0.876892 0.877075 3.84204 0.877075 7.49972C0.877075 11.1574 3.84222 14.1226 7.49991 14.1226C11.1576 14.1226 14.1227 11.1574 14.1227 7.49972C14.1227 3.84204 11.1576 0.876892 7.49991 0.876892ZM1.82707 7.49972C1.82707 4.36671 4.36689 1.82689 7.49991 1.82689C10.6329 1.82689 13.1727 4.36671 13.1727 7.49972C13.1727 10.6327 10.6329 13.1726 7.49991 13.1726C4.36689 13.1726 1.82707 10.6327 1.82707 7.49972ZM8.24992 4.49999C8.24992 4.9142 7.91413 5.24999 7.49992 5.24999C7.08571 5.24999 6.74992 4.9142 6.74992 4.49999C6.74992 4.08577 7.08571 3.74999 7.49992 3.74999C7.91413 3.74999 8.24992 4.08577 8.24992 4.49999ZM6.00003 5.99999H6.50003H7.50003C7.77618 5.99999 8.00003 6.22384 8.00003 6.49999V9.99999H8.50003H9.00003V11H8.50003H7.50003H6.50003H6.00003V9.99999H6.50003H7.00003V6.99999H6.50003H6.00003V5.99999Z"
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</TooltipTrigger>

View File

@ -6,6 +6,7 @@ import {
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button';
import { PricingTable } from '~/components/(marketing)/pricing-table';
@ -34,6 +35,26 @@ export default function PricingPage() {
<PricingTable />
</div>
<div className="mx-auto mt-36 max-w-2xl">
<h2 className="text-center text-2xl font-semibold">
None of these work for you? Try self-hosting!
</h2>
<p className="text-muted-foreground mt-4 text-center leading-relaxed">
Our self-hosted option is great for small teams and individuals who need a simple
solution. You can use our docker based setup to get started in minutes. Take control with
full customizability and data ownership.
</p>
<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">
Get Started
</Link>
</Button>
</div>
</div>
<div className="mx-auto mt-36 max-w-2xl">
{/* FAQ Section */}
@ -96,10 +117,10 @@ export default function PricingPage() {
and join our{' '}
<Link
className="text-documenso-700 font-bold"
href="https://join.slack.com/t/documenso/shared_invite/zt-1vibm8txi-DqsDFtdp44Hn2H5lc~RpPQ"
href="https://documen.so/discord"
target="_blank"
>
Slack Community
Discord Community
</Link>{' '}
to keep up to date, on what the current priorities are. In any case, we are an open
community and welcome all input, technical and non-technical ❤️
@ -147,10 +168,10 @@ export default function PricingPage() {
or{' '}
<a
className="text-documenso-700 font-bold"
href="https://join.slack.com/t/documenso/shared_invite/zt-1vibm8txi-DqsDFtdp44Hn2H5lc~RpPQ"
href="https://documen.so/discord"
target="_blank"
>
in our Slack-Support-Channel
in our Discord-Support-Channel
</a>{' '}
please message either Lucas or Timur to get added to the channel if you are not
already a member.

View File

@ -1,6 +1,7 @@
import { notFound } from 'next/navigation';
import { getDocumentAndRecipientByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { DocumentStatus } from '@documenso/prisma/client';
import { SinglePlayerModeSuccess } from '~/components/(marketing)/single-player-mode/single-player-mode-success';
@ -26,5 +27,7 @@ export default async function SinglePlayerModeSuccessPage({
return notFound();
}
return <SinglePlayerModeSuccess document={document} />;
const signatures = await getRecipientSignatures({ recipientId: document.Recipient.id });
return <SinglePlayerModeSuccess document={document} signatures={signatures} />;
}

View File

@ -0,0 +1,262 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { DocumentDataType, Field, Prisma, Recipient } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
type SinglePlayerModeStep = 'fields' | 'sign';
// !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during
// !: the upgrade of Next.js to v13.5.x.
export const SinglePlayerClient = () => {
const analytics = useAnalytics();
const router = useRouter();
const { toast } = useToast();
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
const [fields, setFields] = useState<Field[]>([]);
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
fields: {
title: 'Add document',
description: 'Upload a document and add fields.',
stepIndex: 1,
onBackStep: uploadedFile
? () => {
setUploadedFile(null);
setFields([]);
}
: undefined,
onNextStep: () => setStep('sign'),
},
sign: {
title: 'Sign',
description: 'Enter your details.',
stepIndex: 2,
onBackStep: () => setStep('fields'),
},
};
const currentDocumentFlow = documentFlow[step];
useEffect(() => {
analytics.startSessionRecording('marketing_session_recording_spm');
return () => {
analytics.stopSessionRecording();
};
}, [analytics]);
/**
* Insert the selected fields into the local state.
*/
const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
if (!uploadedFile) {
return;
}
setFields(
data.fields.map((field, i) => ({
id: i,
documentId: -1,
recipientId: -1,
type: field.type,
page: field.pageNumber,
positionX: new Prisma.Decimal(field.pageX),
positionY: new Prisma.Decimal(field.pageY),
width: new Prisma.Decimal(field.pageWidth),
height: new Prisma.Decimal(field.pageHeight),
customText: '',
inserted: false,
})),
);
analytics.capture('Marketing: SPM - Fields added');
documentFlow.fields.onNextStep?.();
};
/**
* Upload, create, sign and send the document.
*/
const onSignSubmit = async (data: TAddSignatureFormSchema) => {
if (!uploadedFile) {
return;
}
try {
const putFileData = await putFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({
documentData: {
type: putFileData.type,
data: putFileData.data,
},
documentName: uploadedFile.file.name,
signer: data,
fields: fields.map((field) => ({
page: field.page,
type: field.type,
positionX: field.positionX.toNumber(),
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
})),
});
analytics.capture('Marketing: SPM - Document signed', {
signer: data.email,
});
router.push(`/singleplayer/${documentToken}/success`);
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const placeholderRecipient: Recipient = {
id: -1,
documentId: -1,
email: '',
name: '',
token: '',
expired: null,
signedAt: null,
readStatus: 'OPENED',
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
};
const onFileDrop = async (file: File) => {
try {
const arrayBuffer = await file.arrayBuffer();
const fileBase64 = base64.encode(new Uint8Array(arrayBuffer));
setUploadedFile({
file,
fileBase64,
});
analytics.capture('Marketing: SPM - Document uploaded');
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
return (
<div className="mt-6 sm:mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
<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`}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
free account
</Link>{' '}
or view our{' '}
<Link
href={'/pricing'}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
community plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>
</div>
<div className="mt-12 grid w-full grid-cols-12 gap-8">
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
{uploadedFile ? (
<Card gradient>
<CardContent className="p-2">
<LazyPDFViewer
documentData={{
id: '',
data: uploadedFile.fileBase64,
initialData: uploadedFile.fileBase64,
type: DocumentDataType.BYTES_64,
}}
/>
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
)}
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
{/* Add fields to PDF page. */}
{step === 'fields' && (
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onFieldsSubmit}
/>
</fieldset>
)}
{/* Enter user details and signature. */}
{step === 'sign' && (
<AddSignatureFormPartial
documentFlow={documentFlow.sign}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
)}
</DocumentFlowFormContainer>
</div>
</div>
</div>
);
};

View File

@ -1,244 +1,10 @@
'use client';
import { SinglePlayerClient } from './client';
import { useEffect, useState } from 'react';
export const revalidate = 0;
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { Field, Prisma, Recipient } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
type SinglePlayerModeStep = 'fields' | 'sign';
export default function SinglePlayerModePage() {
const analytics = useAnalytics();
const router = useRouter();
const { toast } = useToast();
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
const [fields, setFields] = useState<Field[]>([]);
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
fields: {
title: 'Add document',
description: 'Upload a document and add fields.',
stepIndex: 1,
onBackStep: uploadedFile
? () => {
setUploadedFile(null);
setFields([]);
}
: undefined,
onNextStep: () => setStep('sign'),
},
sign: {
title: 'Sign',
description: 'Enter your details.',
stepIndex: 2,
onBackStep: () => setStep('fields'),
},
};
const currentDocumentFlow = documentFlow[step];
useEffect(() => {
analytics.startSessionRecording('marketing_session_recording_spm');
return () => {
analytics.stopSessionRecording();
};
}, [analytics]);
/**
* Insert the selected fields into the local state.
*/
const onFieldsSubmit = (data: TAddFieldsFormSchema) => {
if (!uploadedFile) {
return;
}
setFields(
data.fields.map((field, i) => ({
id: i,
documentId: -1,
recipientId: -1,
type: field.type,
page: field.pageNumber,
positionX: new Prisma.Decimal(field.pageX),
positionY: new Prisma.Decimal(field.pageY),
width: new Prisma.Decimal(field.pageWidth),
height: new Prisma.Decimal(field.pageHeight),
customText: '',
inserted: false,
})),
);
analytics.capture('Marketing: SPM - Fields added');
documentFlow.fields.onNextStep?.();
};
/**
* Upload, create, sign and send the document.
*/
const onSignSubmit = async (data: TAddSignatureFormSchema) => {
if (!uploadedFile) {
return;
}
try {
const putFileData = await putFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({
documentData: {
type: putFileData.type,
data: putFileData.data,
},
documentName: uploadedFile.file.name,
signer: data,
fields: fields.map((field) => ({
page: field.page,
type: field.type,
positionX: field.positionX.toNumber(),
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
})),
});
analytics.capture('Marketing: SPM - Document signed', {
signer: data.email,
});
router.push(`/singleplayer/${documentToken}/success`);
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const placeholderRecipient: Recipient = {
id: -1,
documentId: -1,
email: '',
name: '',
token: '',
expired: null,
signedAt: null,
readStatus: 'OPENED',
signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT',
};
const onFileDrop = async (file: File) => {
try {
const arrayBuffer = await file.arrayBuffer();
const base64String = base64.encode(new Uint8Array(arrayBuffer));
setUploadedFile({
file,
fileBase64: `data:application/pdf;base64,${base64String}`,
});
analytics.capture('Marketing: SPM - Document uploaded');
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
return (
<div className="mt-6 sm:mt-12">
<div className="text-center">
<h1 className="text-3xl font-bold lg:text-5xl">Single Player Mode</h1>
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
View our{' '}
<Link
href={'/pricing'}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>
community plan
</Link>{' '}
for exclusive features, including the ability to collaborate with multiple signers.
</p>
</div>
<div className="mt-12 grid w-full grid-cols-12 gap-8">
<div className="col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7">
{uploadedFile ? (
<Card gradient>
<CardContent className="p-2">
<LazyPDFViewer document={uploadedFile.fileBase64} />
</CardContent>
</Card>
) : (
<DocumentDropzone className="h-[80vh] max-h-[60rem]" onDrop={onFileDrop} />
)}
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer className="top-24" onSubmit={(e) => e.preventDefault()}>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
{/* Add fields to PDF page. */}
{step === 'fields' && (
<fieldset disabled={!uploadedFile} className="flex h-full flex-col">
<AddFieldsFormPartial
documentFlow={documentFlow.fields}
hideRecipients={true}
recipients={uploadedFile ? [placeholderRecipient] : []}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onFieldsSubmit}
/>
</fieldset>
)}
{/* Enter user details and signature. */}
{step === 'sign' && (
<AddSignatureFormPartial
documentFlow={documentFlow.sign}
numberOfSteps={Object.keys(documentFlow).length}
fields={fields}
onSubmit={onSignSubmit}
requireName={Boolean(fields.find((field) => field.type === 'NAME'))}
requireSignature={Boolean(fields.find((field) => field.type === 'SIGNATURE'))}
/>
)}
</DocumentFlowFormContainer>
</div>
</div>
</div>
);
// !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during
// !: the upgrade of Next.js to v13.5.x.
export default function SingleplayerPage() {
return <SinglePlayerClient />;
}

View File

@ -30,7 +30,7 @@ import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message';
export const ZClaimPlanDialogFormSchema = z.object({
name: z.string().min(3),
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
email: z.string().email(),
});

View File

@ -8,10 +8,10 @@ import { createPortal } from 'react-dom';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { useWindowSize } from '@documenso/lib/client-only/hooks/use-window-size';
export default function ConfettiScreen({
export const ConfettiScreen = ({
numberOfPieces: numberOfPiecesProp = 200,
...props
}: React.ComponentPropsWithoutRef<typeof Confetti> & { duration?: number }) {
}: React.ComponentPropsWithoutRef<typeof Confetti> & { duration?: number }) => {
const isMounted = useIsMounted();
const { width, height } = useWindowSize();
@ -43,4 +43,4 @@ export default function ConfettiScreen({
/>,
document.body,
);
}
};

View File

@ -5,13 +5,12 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu';
import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
export type FooterProps = HTMLAttributes<HTMLDivElement>;
@ -25,16 +24,15 @@ const FOOTER_LINKS = [
{ href: '/pricing', text: 'Pricing' },
{ href: '/singleplayer', text: 'Singleplayer' },
{ href: '/blog', text: 'Blog' },
{ href: '/design-system', text: 'Design' },
{ href: '/open', text: 'Open' },
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
{ href: 'mailto:support@documenso.com', text: 'Support' },
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
{ href: '/privacy', text: 'Privacy' },
];
export const Footer = ({ className, ...props }: FooterProps) => {
const { setTheme } = useTheme();
return (
<div className={cn('border-t py-12', className)} {...props}>
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
@ -63,7 +61,7 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
<div className="grid max-w-xs flex-1 grid-cols-2 gap-x-4 gap-y-2">
{FOOTER_LINKS.map((link, index) => (
<Link
key={index}
@ -76,21 +74,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
))}
</div>
</div>
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap justify-between gap-4 px-8 md:mt-12 lg:mt-24">
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-8 md:mt-12 lg:mt-24">
<p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
<Sun className="h-5 w-5" />
<span className="sr-only">Light</span>
</button>
<button type="button" className="text-muted-foreground" onClick={() => setTheme('dark')}>
<Moon className="h-5 w-5" />
<span className="sr-only">Dark</span>
</button>
<div className="flex flex-wrap">
<ThemeSwitcher />
</div>
</div>
</div>

View File

@ -66,7 +66,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
</Link>
<Link
href="https://app.documenso.com/login"
href="https://app.documenso.com/signin"
target="_blank"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>

View File

@ -39,13 +39,14 @@ export const MENU_NAVIGATION_LINKS = [
{
href: 'mailto:support@documenso.com',
text: 'Support',
target: '_blank',
},
{
href: '/privacy',
text: 'Privacy',
},
{
href: 'https://app.documenso.com/login',
href: 'https://app.documenso.com/signin',
text: 'Sign in',
},
];
@ -78,7 +79,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
staggerChildren: 0.03,
}}
>
{MENU_NAVIGATION_LINKS.map(({ href, text }) => (
{MENU_NAVIGATION_LINKS.map(({ href, text, target }) => (
<motion.div
key={href}
variants={{
@ -100,6 +101,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
href={href}
onClick={() => handleMenuItemClick()}
target={target}
>
{text}
</Link>

View File

@ -1,6 +1,6 @@
'use client';
import { HTMLAttributes, useMemo, useState } from 'react';
import { HTMLAttributes, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
@ -11,8 +11,6 @@ import { usePlausible } from 'next-plausible';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { ClaimPlanDialog } from './claim-plan-dialog';
export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
@ -27,14 +25,6 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
: 'MONTHLY',
);
const planId = useMemo(() => {
if (period === 'MONTHLY') {
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
}
return process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID;
}, [period]);
return (
<div className={cn('', className)} {...props}>
<div className="flex items-center justify-center gap-x-6">
@ -86,33 +76,33 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 md:grid-cols-2 lg:grid-cols-3">
<div
data-plan="self-hosted"
data-plan="free"
className="bg-background shadow-foreground/5 flex flex-col items-center justify-center rounded-lg border px-8 py-12 shadow-lg"
>
<p className="text-foreground text-4xl font-medium">Self Hosted</p>
<p className="text-primary mt-2.5 text-xl font-medium">Free</p>
<p className="text-foreground text-4xl font-medium">Free Plan</p>
<p className="text-primary mt-2.5 text-xl font-medium">$0</p>
<p className="text-foreground mt-4 max-w-[30ch] text-center">
For small teams and individuals who need a simple solution
For small teams and individuals with basic needs.
</p>
<Link
href="https://github.com/documenso/documenso"
target="_blank"
className="mt-6"
onClick={() => event('view-github')}
>
<Button className="rounded-full text-base">View on Github</Button>
</Link>
<Button className="rounded-full text-base" asChild>
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
target="_blank"
className="mt-6"
>
Signup Now
</Link>
</Button>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4 font-medium">Host your own instance</p>
<p className="text-foreground py-4">Full Control</p>
<p className="text-foreground py-4">Customizability</p>
<p className="text-foreground py-4">Docker Ready</p>
<p className="text-foreground py-4">Community Support</p>
<p className="text-foreground py-4">Free, Forever</p>
<p className="text-foreground py-4">5 standard documents per month</p>
<p className="text-foreground py-4">Up to 10 recipients per document</p>
<p className="text-foreground py-4">No credit card required</p>
</div>
<div className="flex-1" />
</div>
<div
@ -131,9 +121,9 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
For fast-growing companies that aim to scale across multiple teams.
</p>
<ClaimPlanDialog planId={planId}>
<Button className="mt-6 rounded-full text-base">Signup Now</Button>
</ClaimPlanDialog>
<Button className="mt-6 rounded-full text-base" asChild>
<Link href={`${process.env.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">

View File

@ -14,6 +14,7 @@ import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/cons
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
@ -24,6 +25,7 @@ import {
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
const ZCreateSinglePlayerDocumentSchema = z.object({
documentData: z.object({
@ -97,11 +99,13 @@ export const createSinglePlayerDocument = async (
});
}
const pdfBytes = await doc.save();
const unsignedPdfBytes = await doc.save();
const documentToken = await prisma.$transaction(
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
const { token } = await prisma.$transaction(
async (tx) => {
const documentToken = alphaid();
const token = alphaid();
// Fetch service user who will be the owner of the document.
const serviceUser = await tx.user.findFirstOrThrow({
@ -110,14 +114,10 @@ export const createSinglePlayerDocument = async (
},
});
const documentDataBytes = Buffer.from(pdfBytes).toString('base64');
const { id: documentDataId } = await tx.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: documentDataBytes,
initialData: documentDataBytes,
},
const { id: documentDataId } = await putFile({
name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
});
// Create document.
@ -137,7 +137,7 @@ export const createSinglePlayerDocument = async (
documentId: document.id,
name: signer.name,
email: signer.email,
token: documentToken,
token,
signedAt: createdAt,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
@ -169,7 +169,7 @@ export const createSinglePlayerDocument = async (
}),
);
return documentToken;
return { document, token };
},
{
maxWait: 5000,
@ -195,10 +195,10 @@ export const createSinglePlayerDocument = async (
subject: 'Document signed',
html: render(template),
text: render(template, { plainText: true }),
attachments: [{ content: Buffer.from(pdfBytes), filename: documentName }],
attachments: [{ content: signedPdfBuffer, filename: documentName }],
});
return documentToken;
return token;
};
/**

View File

@ -5,8 +5,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { base64 } from '@documenso/lib/universal/base64';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentStatus, Signature } from '@documenso/prisma/client';
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
@ -14,53 +13,28 @@ import { DocumentShareButton } from '@documenso/ui/components/document/document-
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import signingCelebration from '~/assets/signing-celebration.png';
import ConfettiScreen from '~/components/(marketing)/confetti-screen';
import { DocumentStatus } from '.prisma/client';
import { ConfettiScreen } from '~/components/(marketing)/confetti-screen';
interface SinglePlayerModeSuccessProps {
className?: string;
document: DocumentWithRecipient;
signatures: Signature[];
}
export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerModeSuccessProps) => {
export const SinglePlayerModeSuccess = ({
className,
document,
signatures,
}: SinglePlayerModeSuccessProps) => {
const { getFlag } = useFeatureFlags();
const isConfettiEnabled = getFlag('marketing_spm_confetti');
const [showDocumentDialog, setShowDocumentDialog] = useState(false);
const [isFetchingDocumentFile, setIsFetchingDocumentFile] = useState(false);
const [documentFile, setDocumentFile] = useState<string | null>(null);
const { toast } = useToast();
const onShowDocumentClick = async () => {
if (isFetchingDocumentFile) {
return;
}
setIsFetchingDocumentFile(true);
try {
const data = await getFile(document.documentData);
setDocumentFile(base64.encode(data));
setShowDocumentDialog(true);
} catch {
toast({
title: 'Something went wrong.',
description: 'We were unable to retrieve the document at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
setIsFetchingDocumentFile(false);
};
const { documentData } = document;
useEffect(() => {
window.scrollTo({ top: 0 });
@ -80,6 +54,7 @@ export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerMod
<SigningCard3D
className="mt-8"
name={document.Recipient.name || document.Recipient.email}
signature={signatures.at(0)}
signingCelebrationImage={signingCelebration}
/>
@ -99,11 +74,7 @@ export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerMod
disabled={document.status !== DocumentStatus.COMPLETED}
/>
<Button
onClick={async () => onShowDocumentClick()}
loading={isFetchingDocumentFile}
className="z-10 col-span-2"
>
<Button onClick={() => setShowDocumentDialog(true)} className="z-10 col-span-2">
Show document
</Button>
</div>
@ -123,7 +94,7 @@ export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerMod
</p>
<DocumentDialog
document={documentFile ?? ''}
documentData={documentData}
open={showDocumentDialog}
onOpenChange={setShowDocumentDialog}
/>

View File

@ -31,7 +31,7 @@ import { FormErrorMessage } from '../form/form-error-message';
const ZWidgetFormSchema = z
.object({
email: z.string().email({ message: 'Please enter a valid email address.' }),
name: z.string().min(3, { message: 'Please enter a valid name.' }),
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
})
.and(
z.union([
@ -41,7 +41,7 @@ const ZWidgetFormSchema = z
}),
z.object({
signatureDataUrl: z.null().or(z.string().max(0)),
signatureText: z.string().min(1),
signatureText: z.string().trim().min(1),
}),
]),
);
@ -226,7 +226,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
type="button"
className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.email?.message}
onClick={() => onNextStepClick()}
onClick={() => step === 'EMAIL' && onNextStepClick()}
>
Next
</Button>
@ -303,7 +303,10 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<div className="mt-12 flex-1" />
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-xs">{stepsRemaining} step(s) until signed</p>
<p className="text-muted-foreground text-xs">
{isValid ? 'Ready for Signing' : `${stepsRemaining} step(s) until signed`}
</p>
<p className="text-muted-foreground block text-xs md:hidden">Minimise contract</p>
</div>
@ -313,6 +316,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
'w-1/3': stepsRemaining === 3,
'w-2/3': stepsRemaining === 2,
'w-11/12': stepsRemaining === 1,
'w-full': isValid,
})}
/>
</div>

View File

@ -2,7 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { randomUUID } from 'crypto';
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata';
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
@ -36,64 +36,38 @@ export default async function handler(
where: {
email: email.toLowerCase(),
},
include: {
Subscription: true,
},
});
if (user && user.Subscription) {
if (user) {
return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/signin`,
});
}
const password = Math.random().toString(36).slice(2, 9);
const passwordHash = hashSync(password);
const { id: userId } = await prisma.user.upsert({
where: {
email: email.toLowerCase(),
},
create: {
email: email.toLowerCase(),
name,
password: passwordHash,
},
update: {
name,
password: passwordHash,
},
});
await redis.set(`user:${userId}:temp-password`, password, {
// expire in 24 hours
ex: 60 * 60 * 24,
});
const signatureDataUrlKey = randomUUID();
const clientReferenceId = randomUUID();
if (signatureDataUrl) {
await redis.set(`signature:${signatureDataUrlKey}`, signatureDataUrl, {
await redis.set(`signature:${clientReferenceId}`, signatureDataUrl, {
// expire in 7 days
ex: 60 * 60 * 24 * 7,
});
}
const metadata: Record<string, string> = {
const metadata: TEarlyAdopterCheckoutMetadataSchema = {
name,
email,
signatureText: signatureText || name,
source: 'landing',
source: 'marketing',
};
if (signatureDataUrl) {
metadata.signatureDataUrl = signatureDataUrlKey;
metadata.signatureDataUrl = clientReferenceId;
}
const checkout = await stripe.checkout.sessions.create({
customer_email: email,
client_reference_id: userId.toString(),
payment_method_types: ['card'],
// Using the UUID here means our webhook will not try to use it as a user ID.
client_reference_id: clientReferenceId,
line_items: [
{
price: planId,
@ -104,9 +78,7 @@ export default async function handler(
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}/pricing?email=${encodeURIComponent(
email,
)}&name=${encodeURIComponent(name)}&planId=${planId}&cancelled=true`,
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}`,
});
if (!checkout.url) {

View File

@ -2,14 +2,18 @@
const path = require('path');
const { version } = require('./package.json');
require('dotenv').config({
path: path.join(__dirname, '../../.env.local'),
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
ENV_FILES.forEach((file) => {
require('dotenv').config({
path: path.join(__dirname, `../../${file}`),
});
});
/** @type {import('next').NextConfig} */
const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: {
serverActions: true,
serverActionsBodySizeLimit: '50mb',
},
reactStrictMode: true,
@ -29,6 +33,14 @@ const config = {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',
},
},
webpack: (config, { isServer }) => {
// fixes: Module not found: Cant resolve ../build/Release/canvas.node
if (isServer) {
config.resolve.alias.canvas = false;
}
return config;
},
async rewrites() {
return [
{
@ -37,6 +49,32 @@ const config = {
},
];
},
async redirects() {
return [
{
permanent: true,
source: '/documents/:id/sign',
destination: '/sign/:token',
has: [
{
type: 'query',
key: 'token',
},
],
},
{
permanent: true,
source: '/documents/:id/signed',
destination: '/sign/:token',
has: [
{
type: 'query',
key: 'token',
},
],
},
];
},
};
module.exports = config;

View File

@ -8,6 +8,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
@ -25,8 +26,8 @@
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "13.4.19",
"next-auth": "4.22.3",
"next": "14.0.0",
"next-auth": "4.24.3",
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",
"perfect-freehand": "^1.2.0",
@ -36,12 +37,14 @@
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.43.9",
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"sharp": "0.32.5",
"ts-pattern": "^5.0.5",
"typescript": "5.1.6",
"zod": "^3.21.4"
"typescript": "5.2.2",
"uqr": "^0.1.2",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/formidable": "^2.0.6",

View File

@ -1,41 +0,0 @@
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types';
export const claimPlan = async ({
name,
email,
planId,
signatureDataUrl,
signatureText,
}: TClaimPlanRequestSchema) => {
const response = await fetch('/api/claim-plan', {
method: 'POST',
body: JSON.stringify({
name,
email,
planId,
signatureDataUrl,
signatureText,
}),
headers: {
'Content-Type': 'application/json',
},
});
const body = await response.json();
if (response.status !== 200) {
throw new Error('Failed to claim plan');
}
const safeBody = ZClaimPlanResponseSchema.safeParse(body);
if (!safeBody.success) {
throw new Error('Failed to claim plan');
}
if ('error' in safeBody.data) {
throw new Error(safeBody.data.error);
}
return safeBody.data.redirectUrl;
};

View File

@ -1,37 +0,0 @@
import { z } from 'zod';
export const ZClaimPlanRequestSchema = z
.object({
email: z
.string()
.email()
.transform((value) => value.toLowerCase()),
name: z.string(),
planId: z.string(),
})
.and(
z.union([
z.object({
signatureDataUrl: z.string().min(1),
signatureText: z.null(),
}),
z.object({
signatureDataUrl: z.null(),
signatureText: z.string().min(1),
}),
]),
);
export type TClaimPlanRequestSchema = z.infer<typeof ZClaimPlanRequestSchema>;
export const ZClaimPlanResponseSchema = z
.object({
redirectUrl: z.string(),
})
.or(
z.object({
error: z.string(),
}),
);
export type TClaimPlanResponseSchema = z.infer<typeof ZClaimPlanResponseSchema>;

View File

@ -8,10 +8,12 @@ import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { Document, User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date';
@ -51,21 +53,45 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
header: 'Title',
accessorKey: 'title',
cell: ({ row }) => {
return <div>{row.original.title}</div>;
return (
<div className="block max-w-[5rem] truncate font-medium md:max-w-[10rem]">
{row.original.title}
</div>
);
},
},
{
header: 'Owner',
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.User.name
? recipientInitials(row.original.User.name)
: row.original.User.email.slice(0, 1).toUpperCase();
return (
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-gray-400">
<span className="text-xs">{row.original.User.name}</span>
</AvatarFallback>
</Avatar>
</Link>
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.User.name}</span>
<span>{row.original.User.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},

View File

@ -2,7 +2,7 @@ import React from 'react';
import { redirect } from 'next/navigation';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { AdminNav } from './nav';
@ -20,7 +20,7 @@ export default async function AdminSectionLayout({ children }: AdminSectionLayou
return (
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
<div className="grid grid-cols-12 gap-x-8 md:mt-8">
<div className="grid grid-cols-12 md:mt-8 md:gap-8">
<AdminNav className="col-span-12 md:col-span-3 md:flex" />
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">{children}</div>

View File

@ -16,7 +16,13 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
const pathname = usePathname();
return (
<div className={cn('flex gap-x-2.5 gap-y-2 md:flex-col', className)} {...props}>
<div
className={cn(
'flex gap-x-2.5 gap-y-2 overflow-hidden overflow-x-auto md:flex-col',
className,
)}
{...props}
>
<Button
variant="ghost"
className={cn(

View File

@ -32,7 +32,7 @@ export default async function AdminStatsPage() {
<div>
<h2 className="text-4xl font-semibold">Instance Stats</h2>
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-4">
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<CardMetric icon={UserIcon} title="Total Users" value={usersCount} />
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
<CardMetric
@ -43,7 +43,7 @@ export default async function AdminStatsPage() {
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
</div>
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-1 lg:grid-cols-2">
<div>
<h3 className="text-3xl font-semibold">Document metrics</h3>

View File

@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';

View File

@ -14,14 +14,14 @@ import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
interface User {
type UserData = {
id: number;
name: string | null;
email: string;
roles: Role[];
Subscription?: SubscriptionLite | null;
Document: DocumentLite[];
}
};
type SubscriptionLite = Pick<
Subscription,
@ -31,7 +31,7 @@ type SubscriptionLite = Pick<
type DocumentLite = Pick<Document, 'id'>;
type UsersDataTableProps = {
users: User[];
users: UserData[];
totalPages: number;
perPage: number;
page: number;

View File

@ -4,7 +4,7 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -32,7 +32,7 @@ export type EditDocumentFormProps = {
document: DocumentWithData;
recipients: Recipient[];
fields: Field[];
dataUrl: string;
documentData: DocumentData;
};
type EditDocumentStep = 'signers' | 'fields' | 'subject';
@ -43,7 +43,7 @@ export const EditDocumentForm = ({
recipients,
fields,
user: _user,
dataUrl,
documentData,
}: EditDocumentFormProps) => {
const { toast } = useToast();
const router = useRouter();
@ -153,7 +153,7 @@ export const EditDocumentForm = ({
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={dataUrl} />
<LazyPDFViewer key={documentData.id} documentData={documentData} />
</CardContent>
</Card>

View File

@ -3,11 +3,10 @@ import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -43,10 +42,6 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
const { documentData } = document;
const documentDataUrl = await getFile(documentData)
.then((buffer) => Buffer.from(buffer).toString('base64'))
.then((data) => `data:application/pdf;base64,${data}`);
const [recipients, fields] = await Promise.all([
await getRecipientsForDocument({
documentId,
@ -90,13 +85,13 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
user={user}
recipients={recipients}
fields={fields}
dataUrl={documentDataUrl}
documentData={documentData}
/>
)}
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer document={documentDataUrl} />
<LazyPDFViewer key={documentData.id} documentData={documentData} />
</div>
)}
</div>

View File

@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { History } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
const FORM_ID = 'resend-email';
export type ResendDocumentActionItemProps = {
document: Document;
recipients: Recipient[];
};
export const ZResendDocumentFormSchema = z.object({
recipients: z.array(z.number()).min(1, {
message: 'You must select at least one item.',
}),
});
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const ResendDocumentActionItem = ({
document,
recipients,
}: ResendDocumentActionItemProps) => {
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const isDisabled =
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
const form = useForm<TResendDocumentFormSchema>({
resolver: zodResolver(ZResendDocumentFormSchema),
defaultValues: {
recipients: [],
},
});
const {
handleSubmit,
formState: { isSubmitting },
} = form;
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients });
toast({
title: 'Document re-sent',
description: 'Your document has been re-sent successfully.',
duration: 5000,
});
setIsOpen(false);
} catch (err) {
toast({
title: 'Something went wrong',
description: 'This document could not be re-sent at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
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>
<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"
>
<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>
<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
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -6,11 +6,9 @@ import { Edit, Pencil, Share } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DataTableActionButtonProps = {
row: Document & {
@ -21,16 +19,11 @@ export type DataTableActionButtonProps = {
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [, copyToClipboard] = useCopyToClipboard();
if (!session) {
return null;
}
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
trpc.shareLink.createOrGetShareLink.useMutation();
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
@ -40,20 +33,6 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onShareClick = async () => {
const { slug } = await createOrGetShareLink({
token: recipient?.token,
documentId: row.id,
});
await copyToClipboard(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`).catch(() => null);
toast({
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
});
};
return match({
isOwner,
isRecipient,
@ -79,9 +58,15 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
</Button>
))
.otherwise(() => (
<Button className="w-24" loading={isCreatingShareLink} onClick={onShareClick}>
{!isCreatingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
Share
</Button>
<DocumentShareButton
documentId={row.id}
token={recipient?.token}
trigger={({ loading }) => (
<Button className="w-24" loading={loading}>
{!loading && <Share className="-ml-1 mr-2 h-4 w-4" />}
Share
</Button>
)}
/>
));
};

View File

@ -8,7 +8,6 @@ import {
Copy,
Download,
Edit,
History,
Loader,
MoreHorizontal,
Pencil,
@ -18,12 +17,12 @@ import {
} from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
DropdownMenu,
DropdownMenuContent,
@ -31,9 +30,10 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ResendDocumentActionItem } from './_action-items/resend-document';
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
export type DataTableActionDropdownProps = {
row: Document & {
@ -44,18 +44,14 @@ export type DataTableActionDropdownProps = {
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [, copyToClipboard] = useCopyToClipboard();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
if (!session) {
return null;
}
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
trpcReact.shareLink.createOrGetShareLink.useMutation();
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
@ -66,20 +62,6 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isDocumentDeletable = isOwner && row.status === DocumentStatus.DRAFT;
const onShareClick = async () => {
const { slug } = await createOrGetShareLink({
token: recipient?.token,
documentId: row.id,
});
await copyToClipboard(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`).catch(() => null);
toast({
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
});
};
const onDownloadClick = async () => {
let document: DocumentWithData | null = null;
@ -115,6 +97,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
window.URL.revokeObjectURL(link.href);
};
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger>
@ -143,7 +126,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Download
</DropdownMenuItem>
<DropdownMenuItem disabled>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
@ -160,19 +143,20 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuLabel>Share</DropdownMenuLabel>
<DropdownMenuItem disabled>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
<DropdownMenuItem disabled={isDraft} onClick={onShareClick}>
{isCreatingShareLink ? (
<Loader className="mr-2 h-4 w-4" />
) : (
<Share className="mr-2 h-4 w-4" />
<DocumentShareButton
documentId={row.id}
token={recipient?.token}
trigger={({ loading, disabled }) => (
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
<div className="flex items-center">
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
Share
</div>
</DropdownMenuItem>
)}
Share
</DropdownMenuItem>
/>
</DropdownMenuContent>
{isDocumentDeletable && (
@ -182,6 +166,13 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
onOpenChange={setDeleteDialogOpen}
/>
)}
{isDuplicateDialogOpen && (
<DuplicateDocumentDialog
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>
)}
</DropdownMenu>
);
};

View File

@ -0,0 +1,105 @@
import { useRouter } from 'next/navigation';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateDocumentDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DuplicateDocumentDialog = ({
id,
open,
onOpenChange,
}: DuplicateDocumentDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
id,
});
const documentData = document?.documentData;
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
router.push(`/documents/${newId}`);
toast({
title: 'Document Duplicated',
description: 'Your document has been successfully duplicated.',
duration: 5000,
});
onOpenChange(false);
},
});
const onDuplicate = async () => {
try {
await duplicateDocument({ id });
} catch {
toast({
title: 'Something went wrong',
description: 'This document could not be duplicated at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate</DialogTitle>
</DialogHeader>
{!documentData || isLoading ? (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document...
</h1>
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll ">
<LazyPDFViewer key={document?.id} documentData={documentData} />
</div>
)}
<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={isDuplicateLoading || isLoading}
loading={isDuplicateLoading}
onClick={onDuplicate}
className="flex-1"
>
Duplicate
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -14,13 +14,13 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
.with(ExtendedDocumentStatus.COMPLETED, () => ({
title: 'Nothing to do',
message:
'There are no completed documents yet. Documents that you have created or received that become completed will appear here later.',
'There are no completed documents yet. Documents that you have created or received will appear here once completed.',
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
title: 'No active drafts',
message:
'There are no active drafts at then current moment. You can upload a document to start drafting.',
'There are no active drafts at the current moment. You can upload a document to start drafting.',
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.ALL, () => ({
@ -32,7 +32,7 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
.otherwise(() => ({
title: 'Nothing to do',
message:
'All documents are currently actioned. Any new documents are sent or recieved they will start to appear here.',
'All documents have been processed. Any new documents that are sent or received will show here.',
icon: CheckCircle2,
}));

View File

@ -1,6 +1,6 @@
import Link from 'next/link';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
@ -8,7 +8,8 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { DocumentsDataTable } from './data-table';
@ -32,7 +33,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
});
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
// const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
@ -45,6 +46,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
},
page,
perPage,
period,
});
const getTabHref = (value: typeof status) => {

View File

@ -6,6 +6,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
@ -22,6 +23,7 @@ export type UploadDocumentProps = {
export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast();
@ -79,7 +81,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
<div className={cn('relative', className)}>
<DocumentDropzone
className="min-h-[40vh]"
disabled={remaining.documents === 0}
disabled={remaining.documents === 0 || !session?.user.emailVerified}
onDrop={onFileDrop}
/>

View File

@ -6,9 +6,11 @@ import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { CommandMenu } from '~/components/(dashboard)/common/command-menu';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
@ -30,6 +32,8 @@ export default async function AuthenticatedDashboardLayout({
return (
<NextAuthProvider session={session}>
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<CommandMenu />
<Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>

View File

@ -5,8 +5,9 @@ import {
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export const createBillingPortal = async () => {

View File

@ -1,13 +1,14 @@
'use server';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export type CreateCheckoutOptions = {
@ -42,13 +43,11 @@ export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
await createCustomer({
user,
});
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
return getCheckoutSession({

View File

@ -4,9 +4,9 @@ import { match } from 'ts-pattern';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { Stripe } from '@documenso/lib/server-only/stripe';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { LocaleDate } from '~/components/formatter/locale-date';
@ -41,7 +41,7 @@ export default async function BillingSettingsPage() {
return (
<div>
<h3 className="text-lg font-medium">Billing</h3>
<h3 className="text-2xl font-semibold">Billing</h3>
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (

View File

@ -1,19 +1,5 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { redirect } from 'next/navigation';
import { PasswordForm } from '~/components/forms/password';
export default async function PasswordSettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<h3 className="text-lg font-medium">Password</h3>
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
<hr className="my-4" />
<PasswordForm user={user} className="max-w-xl" />
</div>
);
export default function PasswordSettingsPage() {
redirect('/settings/security');
}

View File

@ -1,4 +1,4 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { ProfileForm } from '~/components/forms/profile';
@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() {
return (
<div>
<h3 className="text-lg font-medium">Profile</h3>
<h3 className="text-2xl font-semibold">Profile</h3>
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>

View File

@ -0,0 +1,46 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
import { PasswordForm } from '~/components/forms/password';
export default async function SecuritySettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<h3 className="text-2xl font-semibold">Security</h3>
<p className="text-muted-foreground mt-2 text-sm">
Here you can manage your password and security settings.
</p>
<hr className="my-4" />
<PasswordForm user={user} className="max-w-xl" />
<hr className="mb-4 mt-8" />
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
<p className="text-muted-foreground mt-2 text-sm">
Add and manage your two factor security settings to add an extra layer of security to your
account!
</p>
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Two-factor methods</h5>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
</div>
{user.twoFactorEnabled && (
<div className="mt-4 max-w-xl">
<h5 className="font-medium">Recovery methods</h5>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
</div>
)}
</div>
);
}

View File

@ -1,4 +1,5 @@
import { ImageResponse, NextResponse } from 'next/server';
import { ImageResponse } from 'next/og';
import { NextResponse } from 'next/server';
import { P, match } from 'ts-pattern';
@ -55,7 +56,7 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
return new ImageResponse(
(
<div tw="relative flex h-full w-full">
<div tw="relative flex h-full w-full bg-white">
{/* @ts-expect-error Lack of typing from ImageResponse */}
<img src={shareFrameImage} alt="og-share-frame" tw="absolute inset-0 w-full h-full" />
@ -148,6 +149,10 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
weight: 600,
},
],
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
},
},
);
}

View File

@ -7,6 +7,7 @@ import { match } from 'ts-pattern';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
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 { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -46,6 +47,8 @@ export default async function CompletedSigningPage({
return notFound();
}
const signatures = await getRecipientSignatures({ recipientId: recipient.id });
const recipientName =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
@ -54,7 +57,11 @@ export default async function CompletedSigningPage({
return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44">
{/* Card with recipient */}
<SigningCard3D name={recipientName} signingCelebrationImage={signingCelebration} />
<SigningCard3D
name={recipientName}
signature={signatures.at(0)}
signingCelebrationImage={signingCelebration}
/>
<div className="relative mt-6 flex w-full flex-col items-center">
{match(document.status)
@ -72,7 +79,8 @@ export default async function CompletedSigningPage({
))}
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have signed "{document.title}"
You have signed
<span className="mt-1.5 block">"{document.title}"</span>
</h2>
{match(document.status)

View File

@ -11,6 +11,7 @@ import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider';
import { SigningFieldContainer } from './signing-field-container';
export type DateFieldProps = {
@ -25,6 +26,8 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
const [isPending, startTransition] = useTransition();
const { dateFormat } = useRequiredSigningContext();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation();
@ -40,7 +43,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: '',
value: dateFormat,
});
startTransition(() => router.refresh());

View File

@ -16,9 +16,19 @@ import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { DATE_FORMATS } from '~/helpers/constants';
import { useRequiredSigningContext } from './provider';
import { SignDialog } from './sign-dialog';
export type SigningFormProps = {
document: Document;
@ -30,10 +40,13 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
const router = useRouter();
const { data: session } = useSession();
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const { fullName, signature, setFullName, setSignature, dateFormat, setDateFormat } =
useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const hasDateField = fields.find((field) => field.type === 'DATE');
const {
handleSubmit,
formState: { isSubmitting },
@ -45,6 +58,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
const onFormSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fields);
if (!isFieldsValid) {
@ -99,10 +113,34 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>
{hasDateField && (
<div>
<Label htmlFor="date-format">Date Format</Label>
<Select
onValueChange={(value) => {
setDateFormat(value);
}}
defaultValue={dateFormat}
>
<SelectTrigger className="bg-background mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="Signature">Signature</Label>
@ -132,9 +170,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
Cancel
</Button>
<Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
Complete
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
/>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { NextAuthProvider } from '~/providers/next-auth';

View File

@ -125,7 +125,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
type="text"
className="mt-2"
value={localFullName}
onChange={(e) => setLocalFullName(e.target.value)}
onChange={(e) => setLocalFullName(e.target.value.trimStart())}
/>
</div>

View File

@ -3,12 +3,11 @@ import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
@ -47,10 +46,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const { documentData } = document;
const documentDataUrl = await getFile(documentData)
.then((buffer) => Buffer.from(buffer).toString('base64'))
.then((data) => `data:application/pdf;base64,${data}`);
const { user } = await getServerComponentSession();
if (
@ -61,7 +56,11 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
}
return (
<SigningProvider email={recipient.email} fullName={recipient.name} signature={user?.signature}>
<SigningProvider
email={recipient.email}
fullName={user?.email === recipient.email ? user.name : recipient.name}
signature={user?.email === recipient.email ? user.signature : undefined}
>
<div className="mx-auto w-full max-w-screen-xl">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title}
@ -79,7 +78,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
gradient
>
<CardContent className="p-2">
<LazyPDFViewer document={documentDataUrl} />
<LazyPDFViewer key={documentData.id} documentData={documentData} />
</CardContent>
</Card>

View File

@ -9,6 +9,8 @@ export type SigningContextValue = {
setEmail: (_value: string) => void;
signature: string | null;
setSignature: (_value: string | null) => void;
dateFormat: string;
setDateFormat: (_value: string) => void;
};
const SigningContext = createContext<SigningContextValue | null>(null);
@ -31,6 +33,7 @@ export interface SigningProviderProps {
fullName?: string | null;
email?: string | null;
signature?: string | null;
dateFormat?: string | null;
children: React.ReactNode;
}
@ -38,11 +41,13 @@ export const SigningProvider = ({
fullName: initialFullName,
email: initialEmail,
signature: initialSignature,
dateFormat: initialDateFormat,
children,
}: SigningProviderProps) => {
const [fullName, setFullName] = useState(initialFullName || '');
const [email, setEmail] = useState(initialEmail || '');
const [signature, setSignature] = useState(initialSignature || null);
const [dateFormat, setDateFormat] = useState(initialDateFormat || 'yyyy-MM-dd hh:mm a');
return (
<SigningContext.Provider
@ -53,6 +58,8 @@ export const SigningProvider = ({
setEmail,
signature,
setSignature,
dateFormat,
setDateFormat,
}}
>
{children}

View File

@ -0,0 +1,77 @@
import { useState } from 'react';
import { Document, Field } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
export type SignDialogProps = {
isSubmitting: boolean;
document: Document;
fields: Field[];
onSignatureComplete: () => void | Promise<void>;
};
export const SignDialog = ({
isSubmitting,
document,
fields,
onSignatureComplete,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const isComplete = fields.every((field) => field.inserted);
return (
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button
className="w-full"
type="button"
size="lg"
disabled={!isComplete}
loading={isSubmitting}
>
Complete
</Button>
</DialogTrigger>
<DialogContent>
<div className="text-center">
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
You are about to finish signing "{document.title}". Are you sure?
</div>
</div>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowDialog(false);
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!isComplete}
loading={isSubmitting}
onClick={onSignatureComplete}
>
Sign
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -5,7 +5,7 @@ import { ForgotPasswordForm } from '~/components/forms/forgot-password';
export default function ForgotPasswordPage() {
return (
<div>
<h1 className="text-4xl font-semibold">Forgotten your password?</h1>
<h1 className="text-4xl font-semibold">Forgot your password?</h1>
<p className="text-muted-foreground mt-2 text-sm">
No worries, it happens! Enter your email and we'll email you a special link to reset your

View File

@ -25,7 +25,7 @@ export default function SignInPage() {
href="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
>
Forgotten your password?
Forgot your password?
</Link>
</p>
</div>

View File

@ -0,0 +1,97 @@
import Link from 'next/link';
import { AlertTriangle, CheckCircle2, XCircle, XOctagon } from 'lucide-react';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { Button } from '@documenso/ui/primitives/button';
export type PageProps = {
params: {
token: string;
};
};
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
if (!token) {
return (
<div className="w-full">
<div className="mb-4 text-red-300">
<XOctagon />
</div>
<h2 className="text-4xl font-semibold">No token provided</h2>
<p className="text-muted-foreground mt-2 text-base">
It seems that there is no token provided. Please check your email and try again.
</p>
</div>
);
}
const verified = await verifyEmail({ token });
if (verified === null) {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
<p className="text-muted-foreground mt-4">
We were unable to verify your email. If your email is not verified already, please try
again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}
if (!verified) {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
<p className="text-muted-foreground mt-4">
It seems that the provided token has expired. We've just sent you another token, please
check your email and try again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
<p className="text-muted-foreground mt-4">
Your email has been successfully confirmed! You can now use all features of Documenso.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,28 @@
import Link from 'next/link';
import { XCircle } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export default function EmailVerificationWithoutTokenPage() {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Uh oh! Looks like you're missing a token</h2>
<p className="text-muted-foreground mt-4">
It seems that there is no token provided, if you are trying to verify your email please
follow the link in your email.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
import Link from 'next/link';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Button } from '@documenso/ui/primitives/button';
import NotFoundPartial from '~/components/partials/not-found';

View File

@ -0,0 +1,133 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Monitor, Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { useHotkeys } from 'react-hotkeys-hook';
import {
DOCUMENTS_PAGE_SHORTCUT,
SETTINGS_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@documenso/ui/primitives/command';
const DOCUMENTS_PAGES = [
{
label: 'All documents',
path: '/documents?status=ALL',
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
},
{ label: 'Draft documents', path: '/documents?status=DRAFT' },
{ label: 'Completed documents', path: '/documents?status=COMPLETED' },
{ label: 'Pending documents', path: '/documents?status=PENDING' },
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
];
const SETTINGS_PAGES = [
{ label: 'Settings', path: '/settings', shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', '') },
{ label: 'Profile', path: '/settings/profile' },
{ label: 'Password', path: '/settings/password' },
];
export function CommandMenu() {
const { setTheme } = useTheme();
const { push } = useRouter();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [pages, setPages] = useState<string[]>([]);
const currentPage = pages[pages.length - 1];
const toggleOpen = () => {
setOpen((open) => !open);
};
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
useHotkeys('ctrl+k', toggleOpen);
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) {
e.preventDefault();
if (currentPage === undefined) {
setOpen(false);
}
setPages((pages) => pages.slice(0, -1));
}
};
return (
<CommandDialog commandProps={{ onKeyDown: handleKeyDown }} open={open} onOpenChange={setOpen}>
<CommandInput
value={search}
onValueChange={setSearch}
placeholder="Type a command or search..."
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{!currentPage && (
<>
<CommandGroup heading="Documents">
<Commands push={push} pages={DOCUMENTS_PAGES} />
</CommandGroup>
<CommandGroup heading="Settings">
<Commands push={push} pages={SETTINGS_PAGES} />
</CommandGroup>
<CommandGroup heading="Preferences">
<CommandItem onSelect={() => setPages([...pages, 'theme'])}>Change theme</CommandItem>
</CommandGroup>
</>
)}
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
</CommandList>
</CommandDialog>
);
}
const Commands = ({
push,
pages,
}: {
push: (_path: string) => void;
pages: { label: string; path: string; shortcut?: string }[];
}) => {
return pages.map((page) => (
<CommandItem key={page.path} onSelect={() => push(page.path)}>
{page.label}
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
</CommandItem>
));
};
const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => {
const THEMES = useMemo(
() => [
{ label: 'Light Mode', theme: 'light', icon: Sun },
{ label: 'Dark Mode', theme: 'dark', icon: Moon },
{ label: 'System Theme', theme: 'system', icon: Monitor },
],
[],
);
return THEMES.map((theme) => (
<CommandItem key={theme.theme} onSelect={() => setTheme(theme.theme)}>
<theme.icon className="mr-2" />
{theme.label}
</CommandItem>
));
};

View File

@ -4,7 +4,7 @@ import Link from 'next/link';
import {
CreditCard,
Key,
Lock,
LogOut,
User as LucideUser,
Monitor,
@ -87,9 +87,9 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/settings/password" className="cursor-pointer">
<Key className="mr-2 h-4 w-4" />
Password
<Link href="/settings/security" className="cursor-pointer">
<Lock className="mr-2 h-4 w-4" />
Security
</Link>
</DropdownMenuItem>

View File

@ -0,0 +1,123 @@
'use client';
import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { ONE_SECOND } from '@documenso/lib/constants/time';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type VerifyEmailBannerProps = {
email: string;
};
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
const { mutateAsync: sendConfirmationEmail, isLoading } =
trpc.profile.sendConfirmationEmail.useMutation();
const onResendConfirmationEmail = async () => {
try {
setIsButtonDisabled(true);
await sendConfirmationEmail({ email: email });
toast({
title: 'Success',
description: 'Verification email sent successfully.',
});
setIsOpen(false);
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
} catch (err) {
setIsButtonDisabled(false);
toast({
title: 'Error',
description: 'Something went wrong while sending the confirmation email.',
variant: 'destructive',
});
}
};
useEffect(() => {
// Check localStorage to see if we've recently automatically displayed the dialog
// if it was within the past 24 hours, don't show it again
// otherwise, show it again and update the localStorage timestamp
const emailVerificationDialogLastShown = localStorage.getItem(
'emailVerificationDialogLastShown',
);
if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
return;
}
}
setIsOpen(true);
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
}, []);
return (
<>
<div className="bg-yellow-200 dark:bg-yellow-400">
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-yellow-900">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
Verify your email address to unlock all features.
</div>
<div>
<Button
variant="ghost"
className="h-auto px-2.5 py-1.5 text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500"
disabled={isButtonDisabled}
onClick={() => setIsOpen(true)}
size="sm"
>
{isButtonDisabled ? 'Verification Email Sent' : 'Verify Now'}
</Button>
</div>
</div>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogTitle>Verify your email address</DialogTitle>
<DialogDescription>
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox and
click the link in the email to verify your account.
</DialogDescription>
<div>
<Button
disabled={isButtonDisabled}
loading={isLoading}
onClick={onResendConfirmationEmail}
>
{isLoading ? 'Sending...' : 'Resend Confirmation Email'}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -13,18 +13,24 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
return (
<div
className={cn(
'border-border bg-background hover:shadow-border/80 overflow-hidden rounded-lg border shadow shadow-transparent duration-200',
'border-border bg-background hover:shadow-border/80 h-32 max-h-32 max-w-full overflow-hidden rounded-lg border shadow shadow-transparent duration-200',
className,
)}
>
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
<div className="flex items-center">
{Icon && <Icon className="text-muted-foreground mr-2 h-4 w-4" />}
<div className="flex h-full max-h-full flex-col px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
<div className="flex items-start">
{Icon && (
<div className="mr-2 h-4 w-4">
<Icon className="text-muted-foreground h-4 w-4" />
</div>
)}
<h3 className="text-primary-forground flex items-end text-sm font-medium">{title}</h3>
<h3 className="text-primary-forground flex items-end text-sm font-medium leading-tight">
{title}
</h3>
</div>
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">
<p className="text-foreground mt-auto text-4xl font-semibold leading-8">
{typeof value === 'number' ? value.toLocaleString('en-US') : value}
</p>
</div>

View File

@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
import { CreditCard, Lock, User } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@ -35,16 +35,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
<Link href="/settings/password">
<Link href="/settings/security">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/password') && 'bg-secondary',
pathname?.startsWith('/settings/security') && 'bg-secondary',
)}
>
<Key className="mr-2 h-5 w-5" />
Password
<Lock className="mr-2 h-5 w-5" />
Security
</Button>
</Link>

View File

@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
import { CreditCard, Lock, User } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@ -38,16 +38,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
<Link href="/settings/password">
<Link href="/settings/security">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/password') && 'bg-secondary',
pathname?.startsWith('/settings/security') && 'bg-secondary',
)}
>
<Key className="mr-2 h-5 w-5" />
Password
<Lock className="mr-2 h-5 w-5" />
Security
</Button>
</Link>

View File

@ -0,0 +1,58 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog';
import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog';
type AuthenticatorAppProps = {
isTwoFactorEnabled: boolean;
};
export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => {
const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null);
const isEnableDialogOpen = modalState === 'enable';
const isDisableDialogOpen = modalState === 'disable';
return (
<>
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
<div className="flex-1">
<p>Authenticator app</p>
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
Create one-time passwords that serve as a secondary authentication method for confirming
your identity when requested during the sign-in process.
</p>
</div>
<div>
{isTwoFactorEnabled ? (
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
Disable 2FA
</Button>
) : (
<Button onClick={() => setModalState('enable')} size="sm">
Enable 2FA
</Button>
)}
</div>
</div>
<EnableAuthenticatorAppDialog
key={isEnableDialogOpen ? 'open' : 'closed'}
open={isEnableDialogOpen}
onOpenChange={(open) => !open && setModalState(null)}
/>
<DisableAuthenticatorAppDialog
key={isDisableDialogOpen ? 'open' : 'closed'}
open={isDisableDialogOpen}
onOpenChange={(open) => !open && setModalState(null)}
/>
</>
);
};

View File

@ -0,0 +1,161 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
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 ZDisableTwoFactorAuthenticationForm = z.object({
password: z.string().min(6).max(72),
backupCode: z.string(),
});
export type TDisableTwoFactorAuthenticationForm = z.infer<
typeof ZDisableTwoFactorAuthenticationForm
>;
export type DisableAuthenticatorAppDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DisableAuthenticatorAppDialog = ({
open,
onOpenChange,
}: DisableAuthenticatorAppDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: disableTwoFactorAuthentication } =
trpc.twoFactorAuthentication.disable.useMutation();
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
defaultValues: {
password: '',
backupCode: '',
},
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
});
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
disableTwoFactorAuthenticationForm.formState;
const onDisableTwoFactorAuthenticationFormSubmit = async ({
password,
backupCode,
}: TDisableTwoFactorAuthenticationForm) => {
try {
await disableTwoFactorAuthentication({ password, backupCode });
toast({
title: 'Two-factor authentication disabled',
description:
'Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.',
});
flushSync(() => {
onOpenChange(false);
});
router.refresh();
} catch (_err) {
toast({
title: 'Unable to disable two-factor authentication',
description:
'We were unable to disable two-factor authentication for your account. Please ensure that you have entered your password and backup code correctly and try again.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
<DialogHeader>
<DialogTitle>Disable Authenticator App</DialogTitle>
<DialogDescription>
To disable the Authenticator App for your account, please enter your password and a
backup code. If you do not have a backup code available, please contact support.
</DialogDescription>
</DialogHeader>
<Form {...disableTwoFactorAuthenticationForm}>
<form
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
onDisableTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<FormField
name="password"
control={disableTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="backupCode"
control={disableTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full items-center justify-between">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
loading={isDisableTwoFactorAuthenticationSubmitting}
>
Disable 2FA
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,283 @@
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { renderSVG } from 'uqr';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
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';
import { RecoveryCodeList } from './recovery-code-list';
export const ZSetupTwoFactorAuthenticationForm = z.object({
password: z.string().min(6).max(72),
});
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
export const ZEnableTwoFactorAuthenticationForm = z.object({
token: z.string(),
});
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
export type EnableAuthenticatorAppDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const EnableAuthenticatorAppDialog = ({
open,
onOpenChange,
}: EnableAuthenticatorAppDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
trpc.twoFactorAuthentication.setup.useMutation();
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } =
trpc.twoFactorAuthentication.enable.useMutation();
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
defaultValues: {
password: '',
},
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
});
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
setupTwoFactorAuthenticationForm.formState;
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
defaultValues: {
token: '',
},
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
});
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
enableTwoFactorAuthenticationForm.formState;
const step = useMemo(() => {
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
return 'setup';
}
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
return 'enable';
}
return 'view';
}, [
setupTwoFactorAuthenticationData,
isSetupTwoFactorAuthenticationSubmitting,
enableTwoFactorAuthenticationData,
isEnableTwoFactorAuthenticationSubmitting,
]);
const onSetupTwoFactorAuthenticationFormSubmit = async ({
password,
}: TSetupTwoFactorAuthenticationForm) => {
try {
await setupTwoFactorAuthentication({ password });
} catch (_err) {
toast({
title: 'Unable to setup two-factor authentication',
description:
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
variant: 'destructive',
});
}
};
const onEnableTwoFactorAuthenticationFormSubmit = async ({
token,
}: TEnableTwoFactorAuthenticationForm) => {
try {
await enableTwoFactorAuthentication({ code: token });
toast({
title: 'Two-factor authentication enabled',
description:
'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.',
});
} catch (_err) {
toast({
title: 'Unable to setup two-factor authentication',
description:
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
variant: 'destructive',
});
}
};
const onCompleteClick = () => {
flushSync(() => {
onOpenChange(false);
});
router.refresh();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
<DialogHeader>
<DialogTitle>Enable Authenticator App</DialogTitle>
{step === 'setup' && (
<DialogDescription>
To enable two-factor authentication, please enter your password below.
</DialogDescription>
)}
{step === 'view' && (
<DialogDescription>
Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription>
)}
</DialogHeader>
{match(step)
.with('setup', () => {
return (
<Form {...setupTwoFactorAuthenticationForm}>
<form
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
onSetupTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<FormField
name="password"
control={setupTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full items-center justify-between">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
Continue
</Button>
</div>
</form>
</Form>
);
})
.with('enable', () => (
<Form {...enableTwoFactorAuthenticationForm}>
<form
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
onEnableTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<p className="text-muted-foreground text-sm">
To enable two-factor authentication, scan the following QR code using your
authenticator app.
</p>
<div
className="flex h-36 justify-center"
dangerouslySetInnerHTML={{
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
}}
/>
<p className="text-muted-foreground text-sm">
If your authenticator app does not support QR codes, you can use the following
code instead:
</p>
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
{setupTwoFactorAuthenticationData?.secret}
</p>
<p className="text-muted-foreground text-sm">
Once you have scanned the QR code or entered the code manually, enter the code
provided by your authenticator app below.
</p>
<FormField
name="token"
control={enableTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full items-center justify-between">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
Enable 2FA
</Button>
</div>
</form>
</Form>
))
.with('view', () => (
<div>
{enableTwoFactorAuthenticationData?.recoveryCodes && (
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
)}
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
<Button type="button" onClick={() => onCompleteClick()}>
Complete
</Button>
</div>
</div>
))
.exhaustive()}
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,57 @@
import { Copy } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type RecoveryCodeListProps = {
recoveryCodes: string[];
};
export const RecoveryCodeList = ({ recoveryCodes }: RecoveryCodeListProps) => {
const { toast } = useToast();
const [, copyToClipboard] = useCopyToClipboard();
const onCopyRecoveryCodeClick = async (code: string) => {
try {
const result = await copyToClipboard(code);
if (!result) {
throw new Error('Unable to copy recovery code');
}
toast({
title: 'Recovery code copied',
description: 'Your recovery code has been copied to your clipboard.',
});
} catch (_err) {
toast({
title: 'Unable to copy recovery code',
description:
'We were unable to copy your recovery code to your clipboard. Please try again.',
variant: 'destructive',
});
}
};
return (
<div className="grid grid-cols-2 gap-4">
{recoveryCodes.map((code) => (
<div
key={code}
className="bg-muted text-muted-foreground relative rounded-lg p-4 font-mono md:text-center"
>
<span>{code}</span>
<div className="absolute inset-y-0 right-4 flex items-center justify-center">
<button
className="opacity-60 hover:opacity-80"
onClick={() => void onCopyRecoveryCodeClick(code)}
>
<Copy className="h-5 w-5" />
</button>
</div>
</div>
))}
</div>
);
};

View File

@ -0,0 +1,43 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
type RecoveryCodesProps = {
// backupCodes: string[] | null;
isTwoFactorEnabled: boolean;
};
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
<div className="flex-1">
<p>Recovery Codes</p>
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
Recovery codes are used to access your account in the event that you lose access to your
authenticator app.
</p>
</div>
<div>
<Button onClick={() => setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
View Codes
</Button>
</div>
</div>
<ViewRecoveryCodesDialog
key={isOpen ? 'open' : 'closed'}
open={isOpen}
onOpenChange={setIsOpen}
/>
</>
);
};

View File

@ -0,0 +1,151 @@
import { useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
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';
import { RecoveryCodeList } from './recovery-code-list';
export const ZViewRecoveryCodesForm = z.object({
password: z.string().min(6).max(72),
});
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
export type ViewRecoveryCodesDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
const { toast } = useToast();
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } =
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
defaultValues: {
password: '',
},
resolver: zodResolver(ZViewRecoveryCodesForm),
});
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
const step = useMemo(() => {
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
return 'authenticate';
}
return 'view';
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
try {
await viewRecoveryCodes({ password });
} catch (_err) {
toast({
title: 'Unable to view recovery codes',
description:
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
<DialogHeader>
<DialogTitle>View Recovery Codes</DialogTitle>
{step === 'authenticate' && (
<DialogDescription>
To view your recovery codes, please enter your password below.
</DialogDescription>
)}
{step === 'view' && (
<DialogDescription>
Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription>
)}
</DialogHeader>
{match(step)
.with('authenticate', () => {
return (
<Form {...viewRecoveryCodesForm}>
<form
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
className="flex flex-col gap-y-4"
>
<FormField
name="password"
control={viewRecoveryCodesForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full items-center justify-between">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
Continue
</Button>
</div>
</form>
</Form>
);
})
.with('view', () => (
<div>
{viewRecoveryCodesData?.recoveryCodes && (
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
)}
<div className="mt-4 flex flex-row-reverse items-center justify-between">
<Button onClick={() => onOpenChange(false)}>Complete</Button>
</div>
</div>
))
.exhaustive()}
</DialogContent>
</Dialog>
);
};

View File

@ -1,8 +1,8 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
export type AddFieldsActionInput = TAddFieldsFormSchema & {
documentId: number;

View File

@ -1,8 +1,8 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
export type AddSignersActionInput = TAddSignersFormSchema & {
documentId: number;

View File

@ -1,9 +1,9 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
documentId: number;

View File

@ -20,7 +20,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { FormErrorMessage } from '../form/form-error-message';
export const ZProfileFormSchema = z.object({
name: z.string().min(1),
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
signature: z.string().min(1, 'Signature Pad cannot be empty'),
});

View File

@ -3,7 +3,6 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Eye, EyeOff } from 'lucide-react';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FcGoogle } from 'react-icons/fc';
@ -12,23 +11,30 @@ import { z } from 'zod';
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Input, PasswordInput } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ERROR_MESSAGES = {
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
[ErrorCode.USER_MISSING_PASSWORD]:
'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',
};
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
const LOGIN_REDIRECT_PATH = '/documents';
export const ZSignInFormSchema = z.object({
email: z.string().email().min(1),
password: z.string().min(6).max(72),
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
@ -39,33 +45,84 @@ export type SignInFormProps = {
export const SignInForm = ({ className }: SignInFormProps) => {
const { toast } = useToast();
const [showPassword, setShowPassword] = useState(false);
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
>('totp');
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<TSignInFormSchema>({
values: {
email: '',
password: '',
totpCode: '',
backupCode: '',
},
resolver: zodResolver(ZSignInFormSchema),
});
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
const onCloseTwoFactorAuthenticationDialog = () => {
setValue('totpCode', '');
setValue('backupCode', '');
setIsTwoFactorAuthenticationDialogOpen(false);
};
const onToggleTwoFactorAuthenticationMethodClick = () => {
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
if (method === 'totp') {
setValue('backupCode', '');
}
if (method === 'backup') {
setValue('totpCode', '');
}
setTwoFactorAuthenticationMethod(method);
};
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
try {
const result = await signIn('credentials', {
const credentials: Record<string, string> = {
email,
password,
};
if (totpCode) {
credentials.totpCode = totpCode;
}
if (backupCode) {
credentials.backupCode = backupCode;
}
const result = await signIn('credentials', {
...credentials,
callbackUrl: LOGIN_REDIRECT_PATH,
redirect: false,
});
if (result?.error && isErrorCode(result.error)) {
if (result.error === TwoFactorEnabledErrorCode) {
setIsTwoFactorAuthenticationDialogOpen(true);
return;
}
const errorMessage = ERROR_MESSAGES[result.error];
toast({
variant: 'destructive',
description: ERROR_MESSAGES[result.error],
title: 'Unable to sign in',
description: errorMessage ?? 'An unknown error occurred',
});
return;
@ -118,31 +175,14 @@ export const SignInForm = ({ className }: SignInFormProps) => {
<span>Password</span>
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="current-password"
className="bg-background mt-2 pr-10"
{...register('password')}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
<PasswordInput
id="password"
minLength={6}
maxLength={72}
className="bg-background mt-2"
autoComplete="current-password"
{...register('password')}
/>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
@ -173,6 +213,67 @@ export const SignInForm = ({ className }: SignInFormProps) => {
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
<Dialog
open={isTwoFactorAuthenticationDialogOpen}
onOpenChange={onCloseTwoFactorAuthenticationDialog}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Two-Factor Authentication</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
{twoFactorAuthenticationMethod === 'totp' && (
<div>
<Label htmlFor="totpCode" className="text-muted-forground">
Authentication Token
</Label>
<Input
id="totpCode"
type="text"
className="bg-background mt-2"
{...register('totpCode')}
/>
<FormErrorMessage className="mt-1.5" error={errors.totpCode} />
</div>
)}
{twoFactorAuthenticationMethod === 'backup' && (
<div>
<Label htmlFor="backupCode" className="text-muted-forground">
Backup Code
</Label>
<Input
id="backupCode"
type="text"
className="bg-background mt-2"
{...register('backupCode')}
/>
<FormErrorMessage className="mt-1.5" error={errors.backupCode} />
</div>
)}
<div className="mt-4 flex items-center justify-between">
<Button
type="button"
variant="ghost"
onClick={onToggleTwoFactorAuthenticationMethodClick}
>
{twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
</Button>
<Button type="submit" loading={isSubmitting}>
Sign In
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</form>
);
};

View File

@ -19,7 +19,7 @@ import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSignUpFormSchema = z.object({
name: z.string().min(1),
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1),
password: z.string().min(6).max(72),
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),

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