Compare commits

...

117 Commits

Author SHA1 Message Date
9ff44f10a6 chore: add incident blog post 2024-01-17 21:41:00 +11:00
16d97783f2 feat: improve the UX for password protected documents (#780) 2024-01-17 19:32:42 +11:00
91dd10ec9b fix: add symmetric encryption to document passwords 2024-01-17 17:28:28 +11:00
a94b829ee0 fix: tidy code 2024-01-17 17:17:08 +11:00
1bc885478d fix: display the number of documents in mobile view (#837)
This PR fixes #782.
It now displays the document count on mobile view.
2024-01-17 11:10:28 +11:00
560352492d fix: keyboard shortcut ctrl+k fixed (#830) 2024-01-16 13:07:42 +11:00
84b0c2756b fix: fixed the deleting signature block issue on touchscreens (#809)
Fixed the deleting signature block issue on touchscreens, for some
reason the `onClick` event isn't working on the touchscreens that's why
I've added `onTouchEnd` event to delete the signature block when the
user clicks on it.
2024-01-15 19:33:40 +11:00
58b3a127ea chore: fix color for light mode icon (#806) 2024-01-15 10:48:55 +11:00
7e71e06e04 fix: keyboard shortcut ctrl+k default behaviour fixed 2024-01-13 14:19:37 +05:30
68953d1253 feat add documentPassword to documenet meta and improve the ux
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2024-01-12 20:54:59 +05:30
d73ef57794 Merge branch 'main' into fix/bug-798-signatures-block 2024-01-11 16:50:43 +08:00
eeb6a072aa Merge branch 'main' into harkirat/Protect 2024-01-10 10:45:19 +05:30
b09071ebc7 feat: jump to next field (#805)
When the fields are not filled, the button will say "Next field". Clicking on the button takes you to
the unfilled field.
2024-01-10 15:27:18 +11:00
66bb56047a chore: update roadmap links 2024-01-09 14:32:49 +01:00
f9d26e6b3f fix: stepsRemaining value of the early adopters plan's input section (#803) 2024-01-08 19:09:34 +11:00
3054d84ba7 chore: implemented feedback 2024-01-08 09:58:34 +02:00
a71078cbd5 fix: fixed the deleting signature block issue on touchscreens 2024-01-08 13:17:30 +08:00
4fd6a0d5b6 chore: update onOpenChange 2024-01-05 13:06:16 +02:00
fface15a22 feat: jump to next field 2024-01-05 12:56:07 +02:00
6be119ac95 fix: improve document meta logic 2024-01-03 20:10:50 +11:00
b8a45dd5e3 chore: fix package vulnerabilities (#802)
**Description:**

Fixes Dependabot Vulnerabilities listed here,
https://github.com/documenso/documenso/security/dependabot
2024-01-03 13:31:38 +05:30
5660b99df7 chore: fix package vulnerabilities
Signed-off-by: Adithya Krishna <adi@documenso.com>
2024-01-03 13:23:13 +05:30
8c5216cd44 feat: updated one-click deploys (#770)
**Description:**

- Added `railway.toml` to deploy from `/docker/Dockerfile`
- Added Koyeb as a deploy option
2024-01-03 13:13:48 +05:30
e646c4cf08 Merge branch 'main' into feat/1click-deploys 2024-01-03 12:49:59 +05:30
d8cbe1d5ba Merge branch 'main' into harkirat/Protect 2024-01-03 11:34:42 +05:30
5d56b152d6 fix: fixed padding in footer (#794)
fixes: #793
2024-01-03 13:29:13 +11:00
5c16b10dc2 fix: update footer to be responsive 2024-01-03 13:16:27 +11:00
9bcd6e39e7 docs: change url for cloning (#784) 2024-01-03 12:59:33 +11:00
2202fa3d04 chore: update the stale to prevent automatic closure of issues (#799)
Issues that have an open pull request and are currently in the review
period have been closed due to inactivity before.
2024-01-03 12:55:27 +11:00
c1a6a327af chore: update stale workflows 2024-01-03 12:54:32 +11:00
837c17f1f3 chore: hide empty accordion for documents without date field (#800) 2024-01-03 12:44:57 +11:00
d731532fbf chore: hide empty accordion for documents without date field 2024-01-02 04:58:35 +00:00
d02f6774b2 chore: update the stale to prevent automatic closure of issues 2024-01-01 17:41:29 +00:00
77facba8b4 docs(url): change URL for cloning 2023-12-30 18:27:24 -05:00
53c570151f fix lint, description of dialog
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2023-12-29 22:11:44 +05:30
e900706ab0 Merge branch 'documenso:main' into docs/change-url-for-cloning 2023-12-29 08:50:49 -05:00
bed788f78f docs(url): change URL for cloning 2023-12-29 08:48:26 -05:00
72a7dc6c05 fix the console error
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2023-12-29 17:26:33 +05:30
341481d6db fix: trimmed long file names for better UX (#760)
Fixes #755 

### Notes for Reviewers
- The max length of the title is set to be `16`
- If the length of the title is <16 it returns the original one.
- Or else the title will be the first 8 characters (start) and last 8
characters (end)
- The truncated file name will look like `start...end`

### Screenshot for reference


![image](https://github.com/documenso/documenso/assets/88539464/565e4868-7bb1-4b46-9cb0-886d542b8a01)

---------

Co-authored-by: Catalin Pit <25515812+catalinpit@users.noreply.github.com>
2023-12-29 12:18:19 +02:00
8f5634268d feat: add templates to command menu (#786) 2023-12-29 15:16:26 +11:00
5d6f69dc19 fixed padding issue in footer 2023-12-28 23:30:20 +05:30
5307fa6453 fixed padding issue in footer 2023-12-28 23:29:44 +05:30
0bb86963a9 rolled back to original file 2023-12-28 23:27:45 +05:30
fb0d9b8ef9 fixed padding in footer 2023-12-28 23:14:46 +05:30
918c6f19f2 fix: fixed the title box overlapping issue (#785)
The issue is fixed. Now the box is no more overlapping

<img width="305" alt="Screenshot 2023-12-26 at 10 20 32 AM"
src="https://github.com/documenso/documenso/assets/77022877/bd17ed92-7bb0-4f3a-b0f6-173e5f6b5029">
2023-12-28 11:36:46 +02:00
3f89f8725b fix: resolve conflicting z-index values (#788)
## Description

Currently there are various z-index values that are causing:
- Toasts to be placed behind dialog blur background
- Menu being cropped off by header

## Changes Made

- Revert `z-[1000]` back to `z-50` for the header (not exactly sure why
it was bumped)
- Refactor z-indexes over 9000 to start from 1000
- Ensure z-index of toast is higher than dialog
2023-12-28 20:08:19 +11:00
c4800f74b9 feat: update disabled dropzone text (#787)
Update the dropzone so it will display the relevant disabled text based
on the reason it is disabled.
2023-12-28 20:07:29 +11:00
d8eff192fe fix: update date format and add missing default 2023-12-27 14:05:50 +11:00
eb84d7ff3c fix: remove invalid migration 2023-12-27 14:05:49 +11:00
32633f96d2 feat: dateformat and timezone customization (#506) 2023-12-27 14:05:49 +11:00
27b7e29be7 feat: added undo button while drawing signature (#480) 2023-12-27 14:05:49 +11:00
de4a0b2560 chore: update lint-staged config 2023-12-27 14:05:49 +11:00
5a11de1db9 feat: disable upload document animation for unverified users (#749) 2023-12-27 13:54:46 +11:00
f7cf33c61b fix: Layout issue in the singleplayer mode (#759)
Fixes #744
2023-12-26 14:08:05 +11:00
8f4fea2f14 chore: user metrics naming, adopters description (#769)
Rename Metrics
2023-12-26 13:25:45 +11:00
5a32b5cafd fix: added constants for theme variables (#777)
fixes: #776
2023-12-26 10:49:27 +11:00
8d1b960aa8 feat: add templates to command menu 2023-12-25 23:16:56 +00:00
2b25806c33 fix(url): change URL for cloning 2023-12-23 23:26:53 -05:00
6d58e60a65 fix(url): change URL for cloning 2023-12-23 23:18:32 -05:00
2ae9e29903 feat: improve the ux for password protected documents
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2023-12-22 17:24:05 +05:30
dd56836121 chore: update url 2023-12-22 11:44:22 +05:30
e9312ada51 feat: show document title for delete dialog - [DOC-387] (#772) 2023-12-22 15:09:03 +11:00
1c52c7ebcd chore: update copy 2023-12-22 03:43:12 +00:00
495cd35f7c refactor: forms (#697)
Updates our older forms to use the appropriate components bringing them in-line with the rest of our codebase.
2023-12-22 14:36:00 +11:00
JA
5a5d00fb2e fix(webapp): reset delete document dialog (#762)
This PR makes a small but useful tweak to the `DeleteDocumentDialog`.
Now, the input field gets cleared whenever the dialog is opened. Here’s
what’s changed:

1. **Clear Field After Deleting**: After you delete something and open
the dialog again, it won’t show the old, deleted text anymore. It’s
clean and ready for the next delete.

2. **Type Again to Confirm**: If you type something but close the dialog
without deleting, you’ll have to type it again next time. This way, it
makes sure the user really mean to delete something and didn't do it by
mistake.

Demo Link:
See the old vs. new in action here:
https://www.loom.com/share/80eca0d3b1994f7cbcab6f222db2dbfe?sid=ebc6135c-345e-4640-b395-daff190a96e7

It’s a small change, but it makes the delete process safer and smoother.
2023-12-22 14:14:33 +11:00
1aa0fc3101 fix: remove loadingText prop 2023-12-22 01:46:41 +00:00
48cdf43dcb feat: templates (#537)
Adds the basically ability to create and use templates for repetitive document types
2023-12-21 22:05:09 +11:00
1d9593dd0f fix: hotkeys was overlapping with the browser hotkeys (#774)
Issue - The `Ctrl+K` hotkey in our application is conflicting with the
browser's default search hotkey, leading to unintended browser actions.

https://github.com/documenso/documenso/assets/71957674/180b6028-58f7-4cf8-841c-1e13c9d4d355
2023-12-21 21:40:07 +11:00
9ad94f9862 fix: updates from review 2023-12-21 21:37:33 +11:00
972c20f906 chore: tidy migrations 2023-12-21 21:20:37 +11:00
519c645d06 fix: hotkeys was overlapping with the browser hotkeys
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2023-12-21 15:36:24 +05:30
7babd82470 fix: updates from review 2023-12-21 20:42:45 +11:00
298396c86c fix: awaiting in promise.all array 2023-12-21 17:36:35 +11:00
268a5c6508 fix: swap server-actions for trpc mutations 2023-12-21 17:01:12 +11:00
c40c9b20ec Merge branch 'main' into feat/document-templates 2023-12-21 14:25:22 +11:00
84a0c39810 chore: made requested changes
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-20 10:36:06 +05:30
1af909835d chore: updated title to double quotes
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-19 22:25:23 +05:30
01caa949d9 feat: show document title for delete dialog
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-19 22:20:50 +05:30
775a1b774d chore: fix vulnerability with sharp
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-19 17:16:26 +05:30
c949c4701b chore: udpated railway template link
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-19 17:16:26 +05:30
eda635e2db feat: added custom railway config
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-19 17:16:25 +05:30
2056de2e16 feat: added koyeb as a deploy option
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-19 17:16:25 +05:30
075fdd1f88 fix: lint errors 2023-12-16 15:09:02 +11:00
006a559026 Merge branch 'main' into refactor-forms 2023-12-16 13:26:33 +11:00
ff64671e49 fix: stacking issue with notification/toast and header (#764)
Fixes: #763
2023-12-16 12:40:25 +11:00
089ba1c30e Merge branch 'main' into fix/css-stacking-notification 2023-12-16 00:35:38 +05:30
cbcd893cfd fixed z-index 2023-12-16 00:30:52 +05:30
83dfe92d7a refactor: used constant for steps instead of strings (#751)
fixes #750
2023-12-15 15:07:45 +02:00
e43e8f8c4a feat: environment variable to toggle signups (#747)
closes #732
2023-12-15 23:09:01 +11:00
6e10947d00 Merge branch 'main' into feat/732-toggle-signup-form 2023-12-15 21:05:21 +11:00
682cb37786 fix: update auth-options 2023-12-15 20:41:54 +11:00
5809480f02 chore: fix workflows and update package.json file (#758) 2023-12-15 16:55:48 +11:00
88534fa1c6 feat: add multi subscription support (#734)
## Description

Previously we assumed that there can only be 1 subscription per user.
However, that will soon no longer the case with the introduction of the
Teams subscription.

This PR will apply the required migrations to support multiple
subscriptions.

## Changes Made

- Updated the Prisma schema to allow for multiple `Subscriptions` per
`User`
- Added a Stripe `customerId` field to the `User` model
- Updated relevant billing sections to support multiple subscriptions

## Testing Performed

- Tested running the Prisma migration on a demo database created on the
main branch

Will require a lot of additional testing.

## Checklist

- [ ] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [X] I have followed the project's coding style guidelines.

## Additional Notes

Added the following custom SQL statement to the migration:

> DELETE FROM "Subscription" WHERE "planId" IS NULL OR "priceId" IS
NULL;

Prior to deployment this will require changes to Stripe products:
- Adding `type` meta attribute

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2023-12-14 15:22:54 +11:00
f2d4c0721d chore: removed packageManager as we have engines
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-11 23:38:07 +05:30
f9139a54a5 chore: prevent frequent commenting for semantic pr titles
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-11 23:37:28 +05:30
2d931b2c9b chore: fix caching issue in workflows
Signed-off-by: Adithya Krishna <adi@documenso.com>
2023-12-11 23:36:54 +05:30
5c1d30bfbb chore: remove console log 2023-12-10 09:23:31 +05:30
95041fa2e4 fix: build error 2023-12-09 12:05:36 +05:30
49736d2587 Merge branch 'documenso:main' into feat/732-toggle-signup-form 2023-12-09 11:55:55 +05:30
ee5ce78c82 chore: remove unused code 2023-12-09 11:48:46 +05:30
3b3987dcf8 chore: add env to env.example 2023-12-09 11:43:30 +05:30
78a1ee2af0 feat: disable oauth signup when DISABLE_SIGNUP is true 2023-12-09 11:35:45 +05:30
dbdef79263 chore: remove old env variable from docker compose 2023-12-09 10:38:48 +05:30
323380d757 feat: env variable to disable signing up 2023-12-09 10:37:16 +05:30
bc38009392 Merge branch 'main' into refactor-forms 2023-12-08 16:31:13 +11:00
369d08ae6e feat: refactor signin page 2023-12-02 17:54:55 +05:30
a906833657 feat: use password input component 2023-12-02 17:54:19 +05:30
4733f1e84b refactor: password input component 2023-12-02 17:46:16 +05:30
ab0d38eaf4 Merge branch 'main' into refactor-forms 2023-12-02 17:24:06 +05:30
6bbeaa084c refactor: forms 2023-11-30 15:55:29 +05:30
231a307b89 feat: add loading text prop 2023-11-30 15:20:06 +05:30
0b2dce2238 fix: type 2023-11-29 22:37:33 +05:30
1e29dfd823 refactor: reset password form 2023-11-29 22:33:04 +05:30
dc56c2abf2 refactor: password form 2023-11-29 22:32:42 +05:30
62809e9506 refactor: signin page 2023-11-29 22:31:42 +05:30
318dfcafc3 refactor: signup page 2023-11-29 22:31:24 +05:30
4ff8592e8f feat: add password input component 2023-11-29 22:11:55 +05:30
164 changed files with 2817 additions and 1916 deletions

View File

@ -77,16 +77,14 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
# [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags.
NEXT_PUBLIC_POSTHOG_KEY=""
# OPTIONAL: Defines the host to use for PostHog.
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
# OPTIONAL: Leave blank to disable billing.
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# This is only required for the marketing site
# [[REDIS]]

View File

@ -12,6 +12,10 @@ jobs:
if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Node.js
uses: actions/setup-node@v4
with:

View File

@ -12,6 +12,10 @@ jobs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Node.js
uses: actions/setup-node@v4
with:

View File

@ -16,6 +16,24 @@ jobs:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- name: Check PR creator's previous activity
id: check_activity
run: |
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
if [ "$ACTIVITY" -eq 0 ]; then
echo "::set-output name=is_new::true"
else
echo "::set-output name=is_new::false"
fi
- name: Count PRs created by user
id: count_prs
run: |
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
echo "::set-output name=pr_count::$PR_COUNT"
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
@ -36,7 +54,7 @@ jobs:
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
- if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error

View File

@ -15,11 +15,10 @@ jobs:
- uses: actions/stale@v4
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 30
days-before-issue-stale: 30
stale-issue-message: 'This issue has not seen activity for a while. It will be closed in 30 days unless further activity is detected'
days-before-pr-stale: 90
days-before-issue-stale: 90
days-before-issue-close: 180
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
close-issue-message: 'This issue has been closed because of inactivity.'
close-pr-message: 'This PR has been closed because of inactivity.'
exempt-pr-labels: 'WIP,on-hold,needs review'
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned'
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage'

View File

@ -1,7 +1,7 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative",

View File

@ -13,9 +13,9 @@
·
<a href="https://github.com/documenso/documenso/issues">Issues</a>
·
<a href="https://github.com/documenso/documenso/milestones">Roadmap</a>
<a href="https://documen.so/live">Upcoming Releases</a>
·
<a href="https://documen.so/launches">Upcoming Launches</a>
<a href="https://documen.so/roadmap">Roadmap</a>
</p>
</p>
@ -115,10 +115,12 @@ To run Documenso locally, you will need
Want to get up and running quickly? Follow these steps:
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
After forking the repository, clone it to your local device by using the following command:
```sh
git clone https://github.com/documenso/documenso
git clone https://github.com/<your-username>/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.
@ -152,10 +154,12 @@ npm run d
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.
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
After forking the repository, clone it to your local device by using the following command:
```sh
git clone https://github.com/documenso/documenso
git clone https://github.com/<your-username>/documenso
```
2. Run `npm i` in the root directory
@ -280,12 +284,16 @@ WantedBy=multi-user.target
### Railway
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/DjrRRX)
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p)
### Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso)
### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
## Troubleshooting
### I'm not receiving any emails when using the developer quickstart.

View File

@ -0,0 +1,28 @@
---
title: Jan 10th Email Provider Security Incident
description: On January 10th, 2022, we were notified by our email provider that they had experienced a security incident.
authorName: 'Lucas Smith'
authorImage: '/blog/blog-author-lucas.png'
authorRole: 'Co-Founder'
date: 2024-01-17
tags:
- Security
---
On January 10th, 2024 we were notified by our email provider that a security incident had occurred. This security incident which had started on January 7th led to a bad actor obtaining access to their database which contains ours and other customers data.
We understand that during this security incident the following has been accessed:
- Email addresses.
- Metadata on emails sent excluding the email body.
While the incident is unfortunate we are pleased with the remediation and the processes that our email provider has put in place to help avoid this kind of situation in the future. Since the incident, our provider has rectified the issue and has engaged a security company to conduct an exhaustive investigation and to help improve their security posture moving forward.
We remain steadfast in our commitment to our current email provider, and will not be taking any further action with relation to changing providers.
We are now working with our legal counsel to ensure that we provide the appropriate notice to all our customers in each jurisdiction. If you have any further questions on this incident please feel free to contact our support team at [support@documenso.com](mailto:support@documenso.com).
We appreciate your ongoing support in this matter.
You can read more on the incident on our providers blog post below:
[https://resend.com/blog/incident-report-for-january-10-2024](https://resend.com/blog/incident-report-for-january-10-2024)

View File

@ -1,6 +1,6 @@
---
title: Announcing Pre-Seed and Open Metrics
description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
description: We are excited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'

View File

@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania)
## Documenso Merch Shop
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso.
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contributing to Documenso.
<figure>
<MdxNextImage

View File

@ -36,7 +36,7 @@
"react-hook-form": "^7.43.9",
"react-icons": "^4.11.0",
"recharts": "^2.7.2",
"sharp": "0.32.5",
"sharp": "0.33.1",
"typescript": "5.2.2",
"zod": "^3.22.4"
},

View File

@ -6,8 +6,6 @@ declare namespace NodeJS {
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@ -3,7 +3,7 @@
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 type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils';
export type MonthlyNewUsersChartProps = {
@ -22,7 +22,7 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
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>
<h3 className="text-lg font-semibold">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">

View File

@ -3,7 +3,7 @@
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 type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils';
export type MonthlyTotalUsersChartProps = {
@ -22,7 +22,7 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
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>
<h3 className="text-lg font-semibold">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">

View File

@ -29,10 +29,7 @@ export function OpenPageTooltip() {
</svg>
</TooltipTrigger>
<TooltipContent>
<p>
August and earlier: Active subscribers. September and beyond: Numbers of active
subscriptions.
</p>
<p>Active Subscriptions.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@ -1,160 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message';
export const ZClaimPlanDialogFormSchema = z.object({
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
email: z.string().email(),
});
export type TClaimPlanDialogFormSchema = z.infer<typeof ZClaimPlanDialogFormSchema>;
export type ClaimPlanDialogProps = {
className?: string;
planId: string;
children: React.ReactNode;
};
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
const params = useSearchParams();
const analytics = useAnalytics();
const event = usePlausible();
const { toast } = useToast();
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<TClaimPlanDialogFormSchema>({
defaultValues: {
name: params?.get('name') ?? '',
email: params?.get('email') ?? '',
},
resolver: zodResolver(ZClaimPlanDialogFormSchema),
});
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
try {
const delay = new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
const [redirectUrl] = await Promise.all([
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
delay,
]);
event('claim-plan-pricing');
analytics.capture('Marketing: Claim plan', { planId, email });
window.location.href = redirectUrl;
} catch (error) {
event('claim-plan-failed');
analytics.capture('Marketing: Claim plan failure', { planId, email });
toast({
title: 'Something went wrong',
description: error instanceof Error ? error.message : 'Please try again later.',
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isSubmitting && !open) {
reset();
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Claim your plan</DialogTitle>
<DialogDescription className="mt-4">
We're almost there! Please enter your email address and name to claim your plan.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
{params?.get('cancelled') === 'true' && (
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<Info className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<p className="text-sm leading-5 text-yellow-700">
You have cancelled the payment process. If you didn't mean to do this, please
try again.
</p>
</div>
</div>
</div>
)}
<div>
<Label className="text-muted-foreground">Name</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus />
<FormErrorMessage className="mt-1" error={errors.name} />
</div>
<div>
<Label className="text-muted-foreground">Email</Label>
<Input type="email" className="mt-2" {...register('email')} />
<FormErrorMessage className="mt-1" error={errors.email} />
</div>
<Button type="submit" size="lg" loading={isSubmitting}>
Claim the early adopters Plan (
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
: 'Yearly'}
)
</Button>
</fieldset>
</form>
</DialogContent>
</Dialog>
);
};

View File

@ -39,7 +39,7 @@ export const Footer = ({ className, ...props }: FooterProps) => {
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">
<div>
<div className="flex-shrink-0">
<Link href="/">
<Image
src={LogoImage}
@ -64,13 +64,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</div>
</div>
<div className="grid max-w-xs flex-1 grid-cols-2 gap-x-4 gap-y-2">
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
{FOOTER_LINKS.map((link, index) => (
<Link
key={index}
href={link.href}
target={link.target}
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm"
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 break-words text-sm"
>
{link.text}
</Link>

View File

@ -1,9 +1,9 @@
'use client';
import { HTMLAttributes, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible';
@ -16,14 +16,9 @@ export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
export const PricingTable = ({ className, ...props }: PricingTableProps) => {
const params = useSearchParams();
const event = usePlausible();
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() =>
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
? 'YEARLY'
: 'MONTHLY',
);
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY');
return (
<div className={cn('', className)} {...props}>

View File

@ -1,6 +1,7 @@
'use client';
import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react';
import type { HTMLAttributes, KeyboardEvent } from 'react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
@ -26,6 +27,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher';
import { STEP } from '../constants';
import { FormErrorMessage } from '../form/form-error-message';
const ZWidgetFormSchema = z
@ -48,13 +50,16 @@ const ZWidgetFormSchema = z
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
type StepKeys = keyof typeof STEP;
type StepValues = (typeof STEP)[StepKeys];
export type WidgetProps = HTMLAttributes<HTMLDivElement>;
export const Widget = ({ className, children, ...props }: WidgetProps) => {
const { toast } = useToast();
const event = usePlausible();
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL');
const [step, setStep] = useState<StepValues>(STEP.EMAIL);
const [showSigningDialog, setShowSigningDialog] = useState(false);
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
@ -81,28 +86,28 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
const signatureText = watch('signatureText');
const stepsRemaining = useMemo(() => {
if (step === 'NAME') {
if (step === STEP.NAME) {
return 2;
}
if (step === 'SIGN') {
return 1;
if (step === STEP.EMAIL) {
return 3;
}
return 3;
return 1;
}, [step]);
const onNextStepClick = () => {
if (step === 'EMAIL') {
setStep('NAME');
if (step === STEP.EMAIL) {
setStep(STEP.NAME);
setTimeout(() => {
document.querySelector<HTMLElement>('#name')?.focus();
}, 0);
}
if (step === 'NAME') {
setStep('SIGN');
if (step === STEP.NAME) {
setStep(STEP.SIGN);
setTimeout(() => {
document.querySelector<HTMLElement>('#signatureText')?.focus();
@ -226,7 +231,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={() => step === 'EMAIL' && onNextStepClick()}
onClick={() => step === STEP.EMAIL && onNextStepClick()}
>
Next
</Button>
@ -238,7 +243,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<FormErrorMessage error={errors.email} className="mt-1" />
</motion.div>
{(step === 'NAME' || step === 'SIGN') && (
{(step === STEP.NAME || step === STEP.SIGN) && (
<motion.div
key="name"
className="mt-4"

View File

@ -0,0 +1,5 @@
export const STEP = {
EMAIL: 'EMAIL',
NAME: 'NAME',
SIGN: 'SIGN',
} as const;

View File

@ -1,4 +1,4 @@
import { NextApiRequest, NextApiResponse } from 'next';
import type { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto';
import { buffer } from 'micro';
@ -6,7 +6,8 @@ import { buffer } from 'micro';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { updateFile } from '@documenso/lib/universal/upload/update-file';
import { prisma } from '@documenso/prisma';

View File

@ -42,7 +42,7 @@
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"sharp": "0.32.5",
"sharp": "0.33.1",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",
"uqr": "^0.1.2",

View File

@ -6,8 +6,6 @@ declare namespace NodeJS {
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@ -9,7 +9,6 @@ import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Form,
FormControl,
@ -19,6 +18,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
@ -117,7 +117,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
<fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormControl>
<Combobox
<MultiSelectCombobox
listValues={roles}
onChange={(values: string[]) => onChange(values)}
/>

View File

@ -8,7 +8,7 @@ import { Edit, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { Document, Role, Subscription } from '@documenso/prisma/client';
import type { Document, Role, Subscription } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@ -19,7 +19,7 @@ type UserData = {
name: string | null;
email: string;
roles: Role[];
Subscription?: SubscriptionLite | null;
Subscription?: SubscriptionLite[] | null;
Document: DocumentLite[];
};
@ -35,9 +35,16 @@ type UsersDataTableProps = {
totalPages: number;
perPage: number;
page: number;
individualPriceIds: string[];
};
export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => {
export const UsersDataTable = ({
users,
totalPages,
perPage,
page,
individualPriceIds,
}: UsersDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const [searchString, setSearchString] = useState('');
@ -100,7 +107,13 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa
{
header: 'Subscription',
accessorKey: 'subscription',
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
cell: ({ row }) => {
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
individualPriceIds.includes(sub.priceId),
);
return foundIndividualSubscription?.status ?? 'NONE';
},
},
{
header: 'Documents',

View File

@ -1,3 +1,5 @@
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
import { UsersDataTable } from './data-table-users';
import { search } from './fetch-users.actions';
@ -14,12 +16,23 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const perPage = Number(searchParams.perPage) || 10;
const searchString = searchParams.search || '';
const { users, totalPages } = await search(searchString, page, perPage);
const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage),
getPricesByType('individual'),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
return (
<div>
<h2 className="text-4xl font-semibold">Manage users</h2>
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
<UsersDataTable
users={users}
individualPriceIds={individualPriceIds}
totalPages={totalPages}
page={page}
perPage={perPage}
/>
</div>
);
}

View File

@ -4,8 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@ -29,6 +29,7 @@ export type EditDocumentFormProps = {
user: User;
document: DocumentWithData;
recipients: Recipient[];
documentMeta: DocumentMeta | null;
fields: Field[];
documentData: DocumentData;
};
@ -41,6 +42,7 @@ export const EditDocumentForm = ({
document,
recipients,
fields,
documentMeta,
user: _user,
documentData,
}: EditDocumentFormProps) => {
@ -56,6 +58,8 @@ export const EditDocumentForm = ({
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation();
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
title: {
@ -145,14 +149,16 @@ export const EditDocumentForm = ({
};
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message } = data.email;
const { subject, message, timezone, dateFormat } = data.meta;
try {
await sendDocument({
documentId: document.id,
email: {
meta: {
subject,
message,
timezone,
dateFormat,
},
});
@ -174,6 +180,13 @@ export const EditDocumentForm = ({
}
};
const onPasswordSubmit = async (password: string) => {
await setPasswordForDocument({
documentId: document.id,
password,
});
};
const currentDocumentFlow = documentFlow[step];
return (
@ -183,7 +196,13 @@ export const EditDocumentForm = ({
gradient
>
<CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
<LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
onPasswordSubmit={onPasswordSubmit}
/>
</CardContent>
</Card>

View File

@ -3,10 +3,12 @@ import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
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 { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -40,7 +42,24 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
redirect('/documents');
}
const { documentData } = document;
const { documentData, documentMeta } = document;
if (documentMeta?.password) {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const securePassword = Buffer.from(
symmetricDecrypt({
key,
data: documentMeta.password,
}),
).toString('utf-8');
documentMeta.password = securePassword;
}
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
@ -83,6 +102,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
className="mt-8"
document={document}
user={user}
documentMeta={documentMeta}
recipients={recipients}
fields={fields}
documentData={documentData}
@ -91,7 +111,12 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
<LazyPDFViewer
document={document}
key={documentData.id}
documentMeta={documentMeta}
documentData={documentData}
/>
</div>
)}
</div>

View File

@ -128,7 +128,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Download
</DropdownMenuItem>
<DropdownMenuItem disabled>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
@ -165,6 +165,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DeleteDocumentDialog
id={row.id}
status={row.status}
documentTitle={row.title}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
@ -21,6 +21,7 @@ type DeleteDraftDocumentDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
status: DocumentStatus;
documentTitle: string;
};
export const DeleteDocumentDialog = ({
@ -28,6 +29,7 @@ export const DeleteDocumentDialog = ({
open,
onOpenChange,
status,
documentTitle,
}: DeleteDraftDocumentDialogProps) => {
const router = useRouter();
@ -42,7 +44,7 @@ export const DeleteDocumentDialog = ({
toast({
title: 'Document deleted',
description: 'Your document has been successfully deleted.',
description: `"${documentTitle}" has been successfully deleted`,
duration: 5000,
});
@ -50,6 +52,13 @@ export const DeleteDocumentDialog = ({
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onDelete = async () => {
try {
await deleteDocument({ id, status });
@ -72,7 +81,7 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to delete this document?</DialogTitle>
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be
@ -81,7 +90,7 @@ export const DeleteDocumentDialog = ({
</DialogHeader>
{status !== DocumentStatus.DRAFT && (
<div className="mt-8">
<div className="mt-4">
<Input
type="text"
value={inputValue}

View File

@ -41,6 +41,7 @@ export const DuplicateDocumentDialog = ({
trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => {
router.push(`/documents/${newId}`);
toast({
title: 'Document Duplicated',
description: 'Your document has been successfully duplicated.',

View File

@ -88,7 +88,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block">
<span className="ml-1 inline-block opacity-50">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>

View File

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
@ -25,6 +25,7 @@ export type UploadDocumentProps = {
export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter();
const analytics = useAnalytics();
const { data: session } = useSession();
const { toast } = useToast();
@ -35,6 +36,16 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const disabledMessage = useMemo(() => {
if (remaining.documents === 0) {
return 'You have reached your document limit.';
}
if (!session?.user.emailVerified) {
return 'Verify your email to upload documents.';
}
}, [remaining.documents, session?.user.emailVerified]);
const onFileDrop = async (file: File) => {
try {
setIsLoading(true);
@ -90,6 +101,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
<DocumentDropzone
className="min-h-[40vh]"
disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}
/>

View File

@ -4,7 +4,7 @@ import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
import { Button } from '@documenso/ui/primitives/button';

View File

@ -1,46 +1,13 @@
'use server';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getStripeCustomerByUser } 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-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 () => {
const { user } = await getRequiredServerComponentSession();
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// 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,
},
});
}
const { stripeCustomer } = await getStripeCustomerByUser(user);
return getPortalSession({
customerId: stripeCustomer.id,

View File

@ -1,55 +1,36 @@
'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 { getStripeCustomerByUser } 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-component-session';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
export type CreateCheckoutOptions = {
priceId: string;
};
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
const { user } = await getRequiredServerComponentSession();
const session = await getRequiredServerComponentSession();
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
const { user, stripeCustomer } = await getStripeCustomerByUser(session.user);
let stripeCustomer: Stripe.Customer | null = null;
const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id });
// Find the Stripe customer for the current user subscription.
if (existingSubscription?.periodEnd && existingSubscription.periodEnd >= new Date()) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
const foundSubscription = existingSubscriptions.find(
(subscription) =>
subscription.priceId === priceId &&
subscription.periodEnd &&
subscription.periodEnd >= new Date(),
);
if (foundSubscription) {
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
await createCustomer({
user,
});
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
return getCheckoutSession({
customerId: stripeCustomer.id,
priceId,

View File

@ -2,12 +2,15 @@ import { redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
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 type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { type Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { LocaleDate } from '~/components/formatter/locale-date';
@ -15,7 +18,7 @@ import { BillingPlans } from './billing-plans';
import { BillingPortalButton } from './billing-portal-button';
export default async function BillingSettingsPage() {
const { user } = await getRequiredServerComponentSession();
let { user } = await getRequiredServerComponentSession();
const isBillingEnabled = await getServerComponentFlag('app_billing');
@ -24,20 +27,36 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile');
}
const [subscription, prices] = await Promise.all([
getSubscriptionByUserId({ userId: user.id }),
getPricesByInterval(),
if (!user.customerId) {
user = await getStripeCustomerByUser(user).then((result) => result.user);
}
const [subscriptions, prices, individualPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ type: 'individual' }),
getPricesByType('individual'),
]);
const individualPriceIds = individualPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
individualPriceIds.includes(priceId),
);
const subscription =
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
individualUserSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
() => null,
);
}
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
const isMissingOrInactiveOrFreePlan =
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
return (
<div>

View File

@ -5,6 +5,7 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
@ -20,9 +21,6 @@ import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primi
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { addTemplateFields } from '~/components/forms/edit-template/add-template-fields.action';
import { addTemplatePlaceholders } from '~/components/forms/edit-template/add-template-placeholders.action';
export type EditTemplateFormProps = {
className?: string;
user: User;
@ -63,11 +61,14 @@ export const EditTemplateForm = ({
const currentDocumentFlow = documentFlow[step];
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await addTemplatePlaceholders({
await addTemplateSigners({
templateId: template.id,
signers: data.signers,
});

View File

@ -43,11 +43,11 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
await getRecipientsForTemplate({
getRecipientsForTemplate({
templateId,
userId: user.id,
}),
await getFieldsForTemplate({
getFieldsForTemplate({
templateId,
userId: user.id,
}),

View File

@ -58,11 +58,13 @@ export const TemplatesDataTable = ({
const { id } = await createDocumentFromTemplate({
templateId,
});
toast({
title: 'Document created',
description: 'Your document has been created from the template successfully.',
duration: 5000,
});
router.push(`/documents/${id}`);
} catch (err) {
toast({

View File

@ -23,13 +23,13 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
const { toast } = useToast();
const { mutateAsync: deleteDocument, isLoading } = trpcReact.template.deleteTemplate.useMutation({
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Template deleted',
description: 'Your document has been successfully deleted.',
description: 'Your template has been successfully deleted.',
duration: 5000,
});
@ -37,9 +37,9 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
},
});
const onDraftDelete = async () => {
const onDeleteTemplate = async () => {
try {
await deleteDocument({ id });
await deleteTemplate({ id });
} catch {
toast({
title: 'Something went wrong',
@ -73,7 +73,7 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDraftDelete} className="flex-1">
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
Delete
</Button>
</div>

View File

@ -47,8 +47,6 @@ export const DuplicateTemplateDialog = ({
await duplicateTemplate({
templateId: id,
});
router.refresh();
} catch (err) {
toast({
title: 'Error',

View File

@ -49,10 +49,10 @@ export const NewTemplateDialog = () => {
const { toast } = useToast();
const form = useForm<TCreateTemplateFormSchema>({
resolver: zodResolver(ZCreateTemplateFormSchema),
defaultValues: {
name: '',
},
resolver: zodResolver(ZCreateTemplateFormSchema),
});
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =

View File

@ -15,6 +15,8 @@ import { DocumentDownloadButton } from '@documenso/ui/components/document/docume
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { truncateTitle } from '~/helpers/truncate-title';
export type CompletedSigningPageProps = {
params: {
token?: string;
@ -36,6 +38,8 @@ export default async function CompletedSigningPage({
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const { documentData } = document;
const [fields, recipient] = await Promise.all([
@ -89,7 +93,7 @@ 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
<span className="mt-1.5 block">"{document.title}"</span>
<span className="mt-1.5 block">"{truncatedTitle}"</span>
</h2>
{match({ status: document.status, deletedAt: document.deletedAt })

View File

@ -6,8 +6,13 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -16,9 +21,16 @@ import { SigningFieldContainer } from './signing-field-container';
export type DateFieldProps = {
field: FieldWithSignature;
recipient: Recipient;
dateFormat?: string | null;
timezone?: string | null;
};
export const DateField = ({ field, recipient }: DateFieldProps) => {
export const DateField = ({
field,
recipient,
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
}: DateFieldProps) => {
const router = useRouter();
const { toast } = useToast();
@ -35,12 +47,18 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
const isDifferentTime = field.inserted && localDateString !== field.customText;
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
const onSign = async () => {
try {
await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value: '',
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
});
startTransition(() => router.refresh());
@ -75,7 +93,13 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
<SigningFieldContainer
field={field}
onSign={onSign}
onRemove={onRemove}
type="Date"
tooltipText={isDifferentTime ? tooltipText : undefined}
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
@ -87,7 +111,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
)}
{field.inserted && (
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p>
<p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
)}
</SigningFieldContainer>
);

View File

@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -79,7 +79,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@ -34,6 +34,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
const { data: session } = useSession();
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const { mutateAsync: completeDocumentWithToken } =
@ -48,6 +49,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
}, [fields]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(fields);
};
const onFormSubmit = async () => {
setValidateUninsertedFields(true);
@ -92,7 +98,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
disabled={isSubmitting}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
>
<div className={cn('flex flex-1 flex-col')}>
<div
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
<p className="text-muted-foreground mt-2 text-sm">
@ -149,6 +159,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
/>
</div>
</div>

View File

@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@ -2,18 +2,25 @@ import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { truncateTitle } from '~/helpers/truncate-title';
import { DateField } from './date-field';
import { EmailField } from './email-field';
import { SigningForm } from './form';
@ -42,10 +49,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
viewedDocument({ token }).catch(() => null),
]);
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
if (!document || !document.documentData || !recipient) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const { documentData } = document;
const { user } = await getServerComponentSession();
@ -57,6 +68,23 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
redirect(`/sign/${token}/complete`);
}
if (documentMeta?.password) {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
}
const securePassword = Buffer.from(
symmetricDecrypt({
key,
data: documentMeta.password,
}),
).toString('utf-8');
documentMeta.password = securePassword;
}
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
if (document.deletedAt) {
@ -77,7 +105,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
>
<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}
{truncatedTitle}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
@ -92,7 +120,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
gradient
>
<CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
<LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent>
</Card>
@ -111,7 +144,13 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
<NameField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.DATE, () => (
<DateField key={field.id} field={field} recipient={recipient} />
<DateField
key={field.id}
field={field}
recipient={recipient}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
))
.with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} />

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { Document, Field } from '@documenso/prisma/client';
import type { Document, Field } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -9,10 +9,13 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { truncateTitle } from '~/helpers/truncate-title';
export type SignDialogProps = {
isSubmitting: boolean;
document: Document;
fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>;
};
@ -20,30 +23,31 @@ export const SignDialog = ({
isSubmitting,
document,
fields,
fieldsValidated,
onSignatureComplete,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title);
const isComplete = fields.every((field) => field.inserted);
return (
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button
className="w-full"
type="button"
size="lg"
disabled={!isComplete}
onClick={fieldsValidated}
loading={isSubmitting}
>
Complete
{isComplete ? 'Complete' : 'Next field'}
</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?
You are about to finish signing "{truncatedTitle}". Are you sure?
</div>
</div>

View File

@ -127,7 +127,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
};
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@ -2,8 +2,9 @@
import React from 'react';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type SignatureFieldProps = {
field: FieldWithSignature;
@ -11,6 +12,8 @@ export type SignatureFieldProps = {
children: React.ReactNode;
onSign?: () => Promise<void> | void;
onRemove?: () => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature';
tooltipText?: string | null;
};
export const SigningFieldContainer = ({
@ -19,6 +22,8 @@ export const SigningFieldContainer = ({
onSign,
onRemove,
children,
type,
tooltipText,
}: SignatureFieldProps) => {
const onSignFieldClick = async () => {
if (field.inserted) {
@ -46,7 +51,22 @@ export const SigningFieldContainer = ({
/>
)}
{field.inserted && !loading && (
{type === 'Date' && field.inserted && !loading && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
</TooltipTrigger>
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{type !== 'Date' && field.inserted && !loading && (
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}

View File

@ -13,12 +13,14 @@ export default function SignInPage() {
<SignInForm className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
)}
<p className="mt-2.5 text-center">
<Link

View File

@ -1,8 +1,13 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { SignUpForm } from '~/components/forms/signup';
export default function SignUpPage() {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
redirect('/signin');
}
return (
<div>
<h1 className="text-4xl font-semibold">Create a new account</h1>

View File

@ -11,6 +11,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import {
DOCUMENTS_PAGE_SHORTCUT,
SETTINGS_PAGE_SHORTCUT,
TEMPLATES_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts';
import { trpc as trpcReact } from '@documenso/trpc/react';
import {
@ -22,6 +23,7 @@ import {
CommandList,
CommandShortcut,
} from '@documenso/ui/primitives/command';
import { THEMES_TYPE } from '@documenso/ui/primitives/constants';
const DOCUMENTS_PAGES = [
{
@ -38,6 +40,14 @@ const DOCUMENTS_PAGES = [
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
];
const TEMPLATES_PAGES = [
{
label: 'All templates',
path: '/templates',
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
},
];
const SETTINGS_PAGES = [
{
label: 'Settings',
@ -123,10 +133,12 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen);
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true });
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Escape goes to previous page
@ -173,6 +185,9 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
<CommandGroup heading="Documents">
<Commands push={push} pages={DOCUMENTS_PAGES} />
</CommandGroup>
<CommandGroup heading="Templates">
<Commands push={push} pages={TEMPLATES_PAGES} />
</CommandGroup>
<CommandGroup heading="Settings">
<Commands push={push} pages={SETTINGS_PAGES} />
</CommandGroup>
@ -214,9 +229,9 @@ const Commands = ({
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 },
{ label: 'Light Mode', theme: THEMES_TYPE.LIGHT, icon: Sun },
{ label: 'Dark Mode', theme: THEMES_TYPE.DARK, icon: Moon },
{ label: 'System Theme', theme: THEMES_TYPE.SYSTEM, icon: Monitor },
],
[],
);

View File

@ -33,7 +33,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
return (
<header
className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[1000] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[50] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border',
className,
)}

View File

@ -1,9 +1,9 @@
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import { CheckCircle2, Clock, File } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils';

View File

@ -1,8 +1,10 @@
'use client';
import { HTMLAttributes, useEffect, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { DateTime, DateTimeFormatOptions } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { useLocale } from '@documenso/lib/client-only/providers/locale';

View File

@ -23,6 +23,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZDisableTwoFactorAuthenticationForm = z.object({
@ -107,38 +108,42 @@ export const DisableAuthenticatorAppDialog = ({
)}
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>
)}
/>
<fieldset
className="flex flex-col gap-y-4"
disabled={isDisableTwoFactorAuthenticationSubmitting}
>
<FormField
name="password"
control={disableTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<PasswordInput
{...field}
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>
)}
/>
<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>
)}
/>
</fieldset>
<div className="flex w-full items-center justify-between">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>

View File

@ -27,6 +27,7 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list';
@ -178,9 +179,8 @@ export const EnableAuthenticatorAppDialog = ({
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<Input
<PasswordInput
{...field}
type="password"
autoComplete="current-password"
value={field.value ?? ''}
/>

View File

@ -22,7 +22,7 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list';
@ -108,9 +108,8 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<Input
<PasswordInput
{...field}
type="password"
autoComplete="current-password"
value={field.value ?? ''}
/>

View File

@ -1,32 +0,0 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { setFieldsForTemplate } from '@documenso/lib/server-only/field/set-fields-for-template';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
export type AddTemplateFieldsActionInput = TAddTemplateFieldsFormSchema & {
templateId: number;
};
export const addTemplateFields = async ({ templateId, fields }: AddTemplateFieldsActionInput) => {
'use server';
const { user } = await getRequiredServerComponentSession();
await setFieldsForTemplate({
userId: user.id,
templateId,
fields: fields.map((field) => ({
id: field.nativeId,
signerEmail: field.signerEmail,
signerId: field.signerId,
signerToken: field.signerToken,
type: field.type,
pageNumber: field.pageNumber,
pageX: field.pageX,
pageY: field.pageY,
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
})),
});
};

View File

@ -1,28 +0,0 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
export type AddTemplatePlaceholdersActionInput = TAddTemplatePlacholderRecipientsFormSchema & {
templateId: number;
};
export const addTemplatePlaceholders = async ({
templateId,
signers,
}: AddTemplatePlaceholdersActionInput) => {
'use server';
const { user } = await getRequiredServerComponentSession();
await setRecipientsForTemplate({
userId: user.id,
templateId,
recipients: signers.map((signer) => ({
id: signer.nativeId!,
email: signer.email,
name: signer.name!,
})),
});
};

View File

@ -9,9 +9,15 @@ import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZForgotPasswordFormSchema = z.object({
@ -28,18 +34,15 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
const router = useRouter();
const { toast } = useToast();
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<TForgotPasswordFormSchema>({
const form = useForm<TForgotPasswordFormSchema>({
values: {
email: '',
},
resolver: zodResolver(ZForgotPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation();
const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => {
@ -52,29 +55,37 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
duration: 5000,
});
reset();
form.reset();
router.push('/check-email');
};
return (
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
<FormErrorMessage className="mt-1.5" error={errors.email} />
</div>
<Button size="lg" loading={isSubmitting}>
{isSubmitting ? 'Sending Reset Email...' : 'Reset Password'}
</Button>
</form>
<Button size="lg" loading={isSubmitting}>
{isSubmitting ? 'Sending Reset Email...' : 'Reset Password'}
</Button>
</form>
</Form>
);
};

View File

@ -1,23 +1,25 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Eye, EyeOff, Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { User } from '@documenso/prisma/client';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { FormErrorMessage } from '../form/form-error-message';
export const ZPasswordFormSchema = z
.object({
currentPassword: z
@ -48,16 +50,7 @@ export type PasswordFormProps = {
export const PasswordForm = ({ className }: PasswordFormProps) => {
const { toast } = useToast();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<TPasswordFormSchema>({
const form = useForm<TPasswordFormSchema>({
values: {
currentPassword: '',
password: '',
@ -66,6 +59,8 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
resolver: zodResolver(ZPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation();
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
@ -75,7 +70,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
password,
});
reset();
form.reset();
toast({
title: 'Password updated',
@ -101,117 +96,61 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
};
return (
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="current-password" className="text-muted-foreground">
Current Password
</Label>
<div className="relative">
<Input
id="current-password"
type={showCurrentPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="current-password"
className="bg-background mt-2 pr-10"
{...register('currentPassword')}
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<PasswordInput autoComplete="current-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showCurrentPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowCurrentPassword((show) => !show)}
>
{showCurrentPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.currentPassword} />
</div>
<div>
<Label htmlFor="password" className="text-muted-foreground">
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('password')}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
<FormField
control={form.control}
name="repeatedPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Repeat Password</FormLabel>
<FormControl>
<PasswordInput autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
<div>
<Label htmlFor="repeated-password" className="text-muted-foreground">
Repeat Password
</Label>
<div className="relative">
<Input
id="repeated-password"
type={showConfirmPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('repeatedPassword')}
/>
</fieldset>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowConfirmPassword((show) => !show)}
>
{showConfirmPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
<div className="mt-4">
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Updating password...' : 'Update password'}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
</div>
<div className="mt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Update password
</Button>
</div>
</form>
</form>
</Form>
);
};

View File

@ -3,22 +3,27 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { User } from '@documenso/prisma/client';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { FormErrorMessage } from '../form/form-error-message';
export const ZProfileFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
signature: z.string().min(1, 'Signature Pad cannot be empty'),
@ -36,12 +41,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
const { toast } = useToast();
const {
register,
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TProfileFormSchema>({
const form = useForm<TProfileFormSchema>({
values: {
name: user.name ?? '',
signature: user.signature || '',
@ -49,6 +49,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
resolver: zodResolver(ZProfileFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
@ -84,56 +86,57 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
};
return (
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="full-name" className="text-muted-foreground">
Full Name
</Label>
<Input id="full-name" type="text" className="bg-background mt-2" {...register('name')} />
<FormErrorMessage className="mt-1.5" error={errors.name} />
</div>
<div>
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
</div>
<div>
<Label htmlFor="signature" className="text-muted-foreground">
Signature
</Label>
<div className="mt-2">
<Controller
control={control}
name="signature"
render={({ field: { onChange } }) => (
<SignaturePad
className="h-44 w-full"
containerClassName="rounded-lg border bg-background"
defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')}
/>
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormErrorMessage className="mt-1.5" error={errors.signature} />
</div>
</div>
<div className="mt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Update profile
<div>
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
</div>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
<FormItem>
<FormLabel>Signature</FormLabel>
<FormControl>
<SignaturePad
className="h-44 w-full"
containerClassName="rounded-lg border bg-background"
defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Updating profile...' : 'Update profile'}
</Button>
</div>
</form>
</form>
</Form>
);
};

View File

@ -1,11 +1,8 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Eye, EyeOff } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -13,9 +10,15 @@ import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZResetPasswordFormSchema = z
@ -40,15 +43,7 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
const { toast } = useToast();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const {
register,
reset,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TResetPasswordFormSchema>({
const form = useForm<TResetPasswordFormSchema>({
values: {
password: '',
repeatedPassword: '',
@ -56,6 +51,8 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
resolver: zodResolver(ZResetPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation();
const onFormSubmit = async ({ password }: Omit<TResetPasswordFormSchema, 'repeatedPassword'>) => {
@ -65,7 +62,7 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
token,
});
reset();
form.reset();
toast({
title: 'Password updated',
@ -93,81 +90,45 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
};
return (
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="password" className="text-muted-foreground">
<span>Password</span>
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('password')}
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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" />
<FormField
control={form.control}
name="repeatedPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Repeat Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
<div>
<Label htmlFor="repeatedPassword" className="text-muted-foreground">
<span>Repeat Password</span>
</Label>
<div className="relative">
<Input
id="repeated-password"
type={showConfirmPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('repeatedPassword')}
/>
</fieldset>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowConfirmPassword((show) => !show)}
>
{showConfirmPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
</div>
<Button size="lg" loading={isSubmitting}>
{isSubmitting ? 'Resetting Password...' : 'Reset Password'}
</Button>
</form>
<Button type="submit" size="lg" loading={isSubmitting}>
{isSubmitting ? 'Resetting Password...' : 'Reset Password'}
</Button>
</form>
</Form>
);
};

View File

@ -12,9 +12,16 @@ 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, PasswordInput } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
@ -52,12 +59,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
'totp' | 'backup'
>('totp');
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<TSignInFormSchema>({
const form = useForm<TSignInFormSchema>({
values: {
email: '',
password: '',
@ -67,9 +69,11 @@ export const SignInForm = ({ className }: SignInFormProps) => {
resolver: zodResolver(ZSignInFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onCloseTwoFactorAuthenticationDialog = () => {
setValue('totpCode', '');
setValue('backupCode', '');
form.setValue('totpCode', '');
form.setValue('backupCode', '');
setIsTwoFactorAuthenticationDialogOpen(false);
};
@ -78,11 +82,11 @@ export const SignInForm = ({ className }: SignInFormProps) => {
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
if (method === 'totp') {
setValue('backupCode', '');
form.setValue('backupCode', '');
}
if (method === 'backup') {
setValue('totpCode', '');
form.setValue('totpCode', '');
}
setTwoFactorAuthenticationMethod(method);
@ -113,7 +117,6 @@ export const SignInForm = ({ className }: SignInFormProps) => {
if (result?.error && isErrorCode(result.error)) {
if (result.error === TwoFactorEnabledErrorCode) {
setIsTwoFactorAuthenticationDialogOpen(true);
return;
}
@ -156,64 +159,68 @@ export const SignInForm = ({ className }: SignInFormProps) => {
};
return (
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="email" className="text-muted-forground">
Email
</Label>
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
<FormErrorMessage className="mt-1.5" error={errors.email} />
</div>
<div>
<Label htmlFor="password" className="text-muted-forground">
<span>Password</span>
</Label>
<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>
<Button
size="lg"
loading={isSubmitting}
disabled={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or continue with</span>
<div className="bg-border h-px flex-1" />
</div>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or continue with</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
</form>
<Dialog
open={isTwoFactorAuthenticationDialogOpen}
onOpenChange={onCloseTwoFactorAuthenticationDialog}
@ -223,40 +230,40 @@ export const SignInForm = ({ className }: SignInFormProps) => {
<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')}
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}>
{twoFactorAuthenticationMethod === 'totp' && (
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Token</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<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')}
{twoFactorAuthenticationMethod === 'backup' && (
<FormField
control={form.control}
name="backupCode"
render={({ field }) => (
<FormItem>
<FormLabel> Backup Code</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormErrorMessage className="mt-1.5" error={errors.backupCode} />
</div>
)}
)}
</fieldset>
<div className="mt-4 flex items-center justify-between">
<Button
@ -268,12 +275,12 @@ export const SignInForm = ({ className }: SignInFormProps) => {
</Button>
<Button type="submit" loading={isSubmitting}>
Sign In
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</form>
</Form>
);
};

View File

@ -1,11 +1,8 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Eye, EyeOff } from 'lucide-react';
import { signIn } from 'next-auth/react';
import { Controller, useForm } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
@ -13,9 +10,16 @@ import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -38,14 +42,8 @@ export type SignUpFormProps = {
export const SignUpForm = ({ className }: SignUpFormProps) => {
const { toast } = useToast();
const analytics = useAnalytics();
const [showPassword, setShowPassword] = useState(false);
const {
control,
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TSignUpFormSchema>({
const form = useForm<TSignUpFormSchema>({
values: {
name: '',
email: '',
@ -55,6 +53,8 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
resolver: zodResolver(ZSignUpFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
@ -90,93 +90,83 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
};
return (
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="name" className="text-muted-foreground">
Name
</Label>
<Input id="name" type="text" className="bg-background mt-2" {...register('name')} />
{errors.name && <span className="mt-1 text-xs text-red-500">{errors.name.message}</span>}
</div>
<div>
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
{errors.email && <span className="mt-1 text-xs text-red-500">{errors.email.message}</span>}
</div>
<div>
<Label htmlFor="password" className="text-muted-foreground">
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('password')}
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<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" />
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
/>
<div>
<Label htmlFor="password" className="text-muted-foreground">
Sign Here
</Label>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<Controller
control={control}
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
<SignaturePad
className="h-36 w-full"
containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')}
/>
<FormItem>
<FormLabel>Sign Here</FormLabel>
<FormControl>
<SignaturePad
className="h-36 w-full"
containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</fieldset>
<FormErrorMessage className="mt-1.5" error={errors.signature} />
</div>
<Button
size="lg"
loading={isSubmitting}
disabled={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</Button>
</form>
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? 'Signing up...' : 'Sign Up'}
</Button>
</form>
</Form>
);
};

View File

@ -0,0 +1,10 @@
export const truncateTitle = (title: string, maxLength: number = 16) => {
if (title.length <= maxLength) {
return title;
}
const start = title.slice(0, maxLength / 2);
const end = title.slice(-maxLength / 2);
return `${start}.....${end}`;
};

View File

@ -2,7 +2,7 @@
import React from 'react';
import { Session } from 'next-auth';
import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react';
export type NextAuthProviderProps = {

View File

@ -33,7 +33,6 @@ services:
- SMTP_MAIL_USER=username
- SMTP_MAIL_PASSWORD=password
- MAIL_FROM=admin@example.com
- NEXT_PUBLIC_ALLOW_SIGNUP=true
ports:
- 3000:3000
volumes:

View File

@ -1,6 +1,7 @@
/** @type {import('lint-staged').Config} */
module.exports = {
'**/*.{ts,tsx,cts,mts}': ['eslint --fix'],
'**/*.{js,jsx,cjs,mjs}': ['prettier --write'],
'**/*.{yml,mdx}': ['prettier --write'],
'**/*/package.json': ['npm run precommit'],
'**/*.{ts,tsx,cts,mts}': (files) => `eslint --fix ${files.join(' ')}`,
'**/*.{js,jsx,cjs,mjs}': (files) => `prettier --write ${files.join(' ')}`,
'**/*.{yml,mdx}': (files) => `prettier --write ${files.join(' ')}`,
'**/*/package.json': 'npm run precommit',
};

988
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -43,7 +43,6 @@
"turbo": "^1.9.3"
},
"name": "@documenso/root",
"packageManager": "npm@8.19.2",
"workspaces": [
"apps/*",
"packages/*"

View File

@ -1,10 +1,10 @@
import { DateTime } from 'luxon';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { getPricesByType } from '../stripe/get-prices-by-type';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema';
@ -43,23 +43,29 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
let quota = structuredClone(FREE_PLAN_LIMITS);
let remaining = structuredClone(FREE_PLAN_LIMITS);
// Since we store details and allow for past due plans we need to check if the subscription is active.
if (user.Subscription?.status !== SubscriptionStatus.INACTIVE && user.Subscription?.priceId) {
const { product } = await stripe.prices
.retrieve(user.Subscription.priceId, {
expand: ['product'],
})
.catch((err) => {
console.error(err);
throw err;
});
const activeSubscriptions = user.Subscription.filter(
({ status }) => status === SubscriptionStatus.ACTIVE,
);
if (typeof product === 'string') {
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
if (activeSubscriptions.length > 0) {
const individualPrices = await getPricesByType('individual');
for (const subscription of activeSubscriptions) {
const price = individualPrices.find((price) => price.id === subscription.priceId);
if (!price || typeof price.product === 'string' || price.product.deleted) {
continue;
}
const currentQuota = ZLimitsSchema.parse(
'metadata' in price.product ? price.product.metadata : {},
);
// Use the subscription with the highest quota.
if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) {
quota = currentQuota;
remaining = structuredClone(quota);
}
}
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
remaining = structuredClone(quota);
}
const documents = await prisma.document.count({

View File

@ -1,31 +0,0 @@
import { stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
export type CreateCustomerOptions = {
user: User;
};
export const createCustomer = async ({ user }: CreateCustomerOptions) => {
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
if (existingSubscription) {
throw new Error('User already has a subscription');
}
const customer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
return await prisma.subscription.create({
data: {
userId: user.id,
customerId: customer.id,
},
});
};

View File

@ -1,4 +1,8 @@
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
export const getStripeCustomerByEmail = async (email: string) => {
const foundStripeCustomers = await stripe.customers.list({
@ -17,3 +21,74 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => {
return null;
}
};
/**
* Get a stripe customer by user.
*
* Will create a Stripe customer and update the relevant user if one does not exist.
*/
export const getStripeCustomerByUser = async (user: User) => {
if (user.customerId) {
const stripeCustomer = await getStripeCustomerById(user.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer');
}
return {
user,
stripeCustomer,
};
}
let stripeCustomer = await getStripeCustomerByEmail(user.email);
const isSyncRequired = Boolean(stripeCustomer && !stripeCustomer.deleted);
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
customerId: stripeCustomer.id,
},
});
// Sync subscriptions if the customer already exists for back filling the DB
// and local development.
if (isSyncRequired) {
await syncStripeCustomerSubscriptions(user.id, stripeCustomer.id).catch((e) => {
console.error(e);
});
}
return {
user: updatedUser,
stripeCustomer,
};
};
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
const stripeSubscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
});
await Promise.all(
stripeSubscriptions.data.map(async (subscription) =>
onSubscriptionUpdated({
userId,
subscription,
}),
),
);
};

View File

@ -1,4 +1,4 @@
import Stripe from 'stripe';
import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
@ -7,7 +7,14 @@ type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
export const getPricesByInterval = async () => {
export type GetPricesByIntervalOptions = {
/**
* Filter products by their meta 'type' attribute.
*/
type?: 'individual';
};
export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = {}) => {
let { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
@ -19,8 +26,10 @@ export const getPricesByInterval = async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product;
const filter = !type || product.metadata?.type === type;
// Filter out prices for products that are not active.
return product.active;
return product.active && filter;
});
const intervals: PriceIntervals = {

View File

@ -0,0 +1,11 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export const getPricesByType = async (type: 'individual') => {
const { data: prices } = await stripe.prices.search({
query: `metadata['type']:'${type}' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
return prices;
};

View File

@ -75,23 +75,23 @@ export const stripeWebhookHandler = async (
// Finally, attempt to get the user ID from the subscription within the database.
if (!userId && customerId) {
const result = await prisma.subscription.findFirst({
const result = await prisma.user.findFirst({
select: {
userId: true,
id: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
userId = result.userId;
userId = result.id;
}
const subscriptionId =
@ -124,23 +124,23 @@ export const stripeWebhookHandler = async (
? subscription.customer
: subscription.customer.id;
const result = await prisma.subscription.findFirst({
const result = await prisma.user.findFirst({
select: {
userId: true,
id: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({
success: true,
@ -182,23 +182,23 @@ export const stripeWebhookHandler = async (
});
}
const result = await prisma.subscription.findFirst({
const result = await prisma.user.findFirst({
select: {
userId: true,
id: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({
success: true,
@ -233,23 +233,23 @@ export const stripeWebhookHandler = async (
});
}
const result = await prisma.subscription.findFirst({
const result = await prisma.user.findFirst({
select: {
userId: true,
id: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({
success: true,

View File

@ -1,4 +1,4 @@
import { Stripe } from '@documenso/lib/server-only/stripe';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
@ -7,12 +7,9 @@ export type OnSubscriptionDeletedOptions = {
};
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
await prisma.subscription.update({
where: {
customerId,
planId: subscription.id,
},
data: {
status: SubscriptionStatus.INACTIVE,

View File

@ -1,6 +1,6 @@
import { match } from 'ts-pattern';
import { Stripe } from '@documenso/lib/server-only/stripe';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
@ -13,9 +13,6 @@ export const onSubscriptionUpdated = async ({
userId,
subscription,
}: OnSubscriptionUpdatedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
@ -23,22 +20,22 @@ export const onSubscriptionUpdated = async ({
await prisma.subscription.upsert({
where: {
customerId,
planId: subscription.id,
},
create: {
customerId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
customerId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
};

View File

@ -13,7 +13,7 @@
"eslint-config-next": "13.4.19",
"eslint-config-prettier": "^8.8.0",
"eslint-config-turbo": "^1.9.3",
"eslint-plugin-package-json": "^0.1.4",
"eslint-plugin-package-json": "^0.2.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-unused-imports": "^3.0.0",

View File

@ -1,4 +1,5 @@
import { ReadStatus, Recipient, SendStatus, SigningStatus } from '@documenso/prisma/client';
import type { Recipient } from '@documenso/prisma/client';
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientType = (recipient: Recipient) => {
if (

View File

@ -0,0 +1,79 @@
import { DateTime } from 'luxon';
import { DEFAULT_DOCUMENT_TIME_ZONE } from './time-zones';
export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
export const DATE_FORMATS = [
{
key: 'yyyy-MM-dd_hh:mm_a',
label: 'YYYY-MM-DD HH:mm a',
value: DEFAULT_DOCUMENT_DATE_FORMAT,
},
{
key: 'YYYYMMDD',
label: 'YYYY-MM-DD',
value: 'YYYY-MM-DD',
},
{
key: 'DDMMYYYY',
label: 'DD/MM/YYYY',
value: 'dd/MM/yyyy hh:mm a',
},
{
key: 'MMDDYYYY',
label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy hh:mm a',
},
{
key: 'YYYYMMDDHHmm',
label: 'YYYY-MM-DD HH:mm',
value: 'yyyy-MM-dd HH:mm',
},
{
key: 'YYMMDD',
label: 'YY-MM-DD',
value: 'yy-MM-dd hh:mm a',
},
{
key: 'YYYYMMDDhhmmss',
label: 'YYYY-MM-DD HH:mm:ss',
value: 'yyyy-MM-dd HH:mm:ss',
},
{
key: 'MonthDateYear',
label: 'Month Date, Year',
value: 'MMMM dd, yyyy hh:mm a',
},
{
key: 'DayMonthYear',
label: 'Day, Month Year',
value: 'EEEE, MMMM dd, yyyy hh:mm a',
},
{
key: 'ISO8601',
label: 'ISO 8601',
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
},
];
export const convertToLocalSystemFormat = (
customText: string,
dateFormat: string | null = DEFAULT_DOCUMENT_DATE_FORMAT,
timeZone: string | null = DEFAULT_DOCUMENT_TIME_ZONE,
): string => {
const coalescedDateFormat = dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT;
const coalescedTimeZone = timeZone ?? DEFAULT_DOCUMENT_TIME_ZONE;
const parsedDate = DateTime.fromFormat(customText, coalescedDateFormat, {
zone: coalescedTimeZone,
});
if (!parsedDate.isValid) {
return 'Invalid date';
}
const formattedDate = parsedDate.toLocal().toFormat(coalescedDateFormat);
return formattedDate;
};

View File

@ -1,2 +1,3 @@
export const SETTINGS_PAGE_SHORTCUT = 'N+S';
export const DOCUMENTS_PAGE_SHORTCUT = 'N+D';
export const TEMPLATES_PAGE_SHORTCUT = 'N+T';

View File

@ -0,0 +1,44 @@
import { rawTimeZones, timeZonesNames } from '@vvo/tzdb';
export const TIME_ZONE_DATA = rawTimeZones;
export const DEFAULT_DOCUMENT_TIME_ZONE = 'Etc/UTC';
export type TimeZone = {
name: string;
rawOffsetInMinutes: number;
};
export const minutesToHours = (minutes: number): string => {
const hours = Math.abs(Math.floor(minutes / 60));
const min = Math.abs(minutes % 60);
const sign = minutes >= 0 ? '+' : '-';
return `${sign}${String(hours).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
};
const getGMTOffsets = (timezones: TimeZone[]): string[] => {
const gmtOffsets: string[] = [];
for (const timezone of timezones) {
const offsetValue = minutesToHours(timezone.rawOffsetInMinutes);
const gmtText = `(${offsetValue})`;
gmtOffsets.push(`${timezone.name} ${gmtText}`);
}
return gmtOffsets;
};
export const splitTimeZone = (input: string | null): string => {
if (input === null) {
return '';
}
const [timeZone] = input.split('(');
return timeZone.trim();
};
export const TIME_ZONES_FULL = getGMTOffsets(TIME_ZONE_DATA);
export const TIME_ZONES = ['Etc/UTC', ...timeZonesNames];

View File

@ -162,5 +162,17 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
return session;
},
async signIn({ user }) {
// We do this to stop OAuth providers from creating an account
// when signups are disabled
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
const userData = await getUserByEmail({ email: user.email! });
return !!userData;
}
return true;
},
},
};

View File

@ -31,6 +31,7 @@
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
"bcrypt": "^5.1.0",
"luxon": "^3.4.0",
"nanoid": "^4.0.2",

View File

@ -19,10 +19,11 @@ export const getRecipientsStats = async () => {
results.forEach((result) => {
const { readStatus, signingStatus, sendStatus, _count } = result;
stats[readStatus as keyof typeof stats] += _count;
stats.TOTAL_RECIPIENTS += _count;
stats[signingStatus as keyof typeof stats] += _count;
stats[sendStatus as keyof typeof stats] += _count;
stats[readStatus] += _count;
stats[signingStatus] += _count;
stats[sendStatus] += _count;
stats.TOTAL_RECIPIENTS += _count;
});

View File

@ -9,7 +9,9 @@ export const getUsersWithSubscriptionsCount = async () => {
return await prisma.user.count({
where: {
Subscription: {
status: SubscriptionStatus.ACTIVE,
some: {
status: SubscriptionStatus.ACTIVE,
},
},
},
});

View File

@ -4,15 +4,30 @@ import { prisma } from '@documenso/prisma';
export type CreateDocumentMetaOptions = {
documentId: number;
subject: string;
message: string;
subject?: string;
message?: string;
timezone?: string;
password?: string;
dateFormat?: string;
userId: number;
};
export const upsertDocumentMeta = async ({
subject,
message,
timezone,
dateFormat,
documentId,
userId,
password,
}: CreateDocumentMetaOptions) => {
await prisma.document.findFirstOrThrow({
where: {
id: documentId,
userId,
},
});
return await prisma.documentMeta.upsert({
where: {
documentId,
@ -20,11 +35,17 @@ export const upsertDocumentMeta = async ({
create: {
subject,
message,
dateFormat,
timezone,
password,
documentId,
},
update: {
subject,
message,
dateFormat,
password,
timezone,
},
});
};

View File

@ -25,6 +25,9 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
select: {
message: true,
subject: true,
dateFormat: true,
password: true,
timezone: true,
},
},
},

View File

@ -0,0 +1,13 @@
import { prisma } from '@documenso/prisma';
export interface GetDocumentMetaByDocumentIdOptions {
id: number;
}
export const getDocumentMetaByDocumentId = async ({ id }: GetDocumentMetaByDocumentIdOptions) => {
return await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: id,
},
});
};

View File

@ -1,6 +1,6 @@
'use server';
import { Prisma } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';

View File

@ -1,5 +1,6 @@
import { prisma } from '@documenso/prisma';
import { FieldType, SendStatus, SigningStatus } from '@documenso/prisma/client';
import type { FieldType } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetFieldsForDocumentOptions {
userId: number;

View File

@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma';
import { FieldType } from '@documenso/prisma/client';
import type { FieldType } from '@documenso/prisma/client';
export type Field = {
id?: number | null;
@ -32,7 +32,7 @@ export const setFieldsForTemplate = async ({
});
if (!template) {
throw new Error('Document not found');
throw new Error('Template not found');
}
const existingFields = await prisma.field.findMany({
@ -93,8 +93,10 @@ export const setFieldsForTemplate = async ({
},
Recipient: {
connect: {
id: field.signerId,
email: field.signerEmail,
templateId_email: {
templateId,
email: field.signerEmail.toLowerCase(),
},
},
},
},

View File

@ -5,6 +5,9 @@ import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
export type SignFieldWithTokenOptions = {
token: string;
fieldId: number;
@ -58,6 +61,12 @@ export const signFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`);
}
const documentMeta = await prisma.documentMeta.findFirst({
where: {
documentId: document.id,
},
});
const isSignatureField =
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
@ -67,7 +76,9 @@ export const signFieldWithToken = async ({
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
if (field.type === FieldType.DATE) {
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
customText = DateTime.now()
.setZone(documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
.toFormat(documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
}
if (isSignatureField && !signatureImageAsBase64 && !typedSignature) {

View File

@ -1,15 +0,0 @@
'use server';
import { prisma } from '@documenso/prisma';
export type GetSubscriptionByUserIdOptions = {
userId: number;
};
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
return await prisma.subscription.findFirst({
where: {
userId,
},
});
};

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