Compare commits

..

91 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan
f26ab295d2 fix: avoid document title with only whitespaces 2024-02-05 19:29:37 +00:00
Catalin Pit
c6457e75e0 feat: migration for verifying paid users (#889) 2024-02-05 14:17:01 +11:00
Sumit Bisht
f5930dc934 perf: mentioned type and size of the doc to be uploaded (#867)
explicitly mentioned "PDF" to upload, and added a toast if pdf size is
greater than 50mb

fixes: #621
2024-02-05 12:50:35 +11:00
Lucas Smith
8f3a52e1fd fix: update e2e test 2024-02-02 04:49:42 +00:00
Ashraf Chowdury
861225b7c4 fix: Prevent users from bypassing document limitations (#898)
## Description

**Fixed document limitation bypassing issues through templates.**
Previously, users could bypass document restrictions by utilizing
templates even after reaching their limitations. This fix ensures that
templates will no longer function as a workaround when users reach their
document limits.

## Changes
1. imported `useLimits` hook on `data-table-templates.tsx`
2. Disabled the 'Use Template' button when the user reaches their limit.
3. Added an Alert Component on top of the templates page to notify users
that they can't use templates anymore because they have reached their
limit.
4. Used `getServerLimits` hook on `template-router` to a condition on
the server.

## Example

![image](https://github.com/documenso/documenso/assets/87828904/275e83ea-ca7b-4b0e-83f4-ac10da9aff6a)

## Issue
Closes #883
2024-02-02 15:48:42 +11:00
Lucas Smith
7210d48b64 fix: update dockerfile to support encryption keys 2024-02-02 04:37:29 +00:00
Lucas Smith
5cf2f0a30e fix: only throw crypto key errors on server 2024-02-02 04:24:49 +00:00
Hani
7ece6ef239 feat: add recipient roles (#716)
Fixes #705

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
Co-authored-by: David Nguyen <davidngu28@gmail.com>
2024-02-02 10:45:02 +11:00
Lucas Smith
e42088a5bf feat: add user security audit logs (#884)
## Description

Adds the ability to see the events relating to the account.

Event data includes:
- Device
- IP Address
- Time
- Action

Actions are:

- Profile update
- Account linked to SSO (Example user signs in with Google after
creating a email/password account)
- Enable 2FA
- Disable 2FA
- Reset password
- Update password
- Sign out
- Sign in
- Sign in fail
- Sign in 2FA fail

## Changes

- Added audit logs
- Updated 2FA dialogs to have consistent footers
- Update `/settings/security/page` layout

## Testing Performed

Tested events:


![image](https://github.com/documenso/documenso/assets/20962767/8ab9e055-aa58-4621-86fe-24681cce6418)

More tested events:


![image](https://github.com/documenso/documenso/assets/20962767/b6b42e13-626e-4fed-8e1a-097e5324aa6d)

## Checklist

- [X] I have tested these changes locally and they work as expected.
- [X] I have followed the project's coding style guidelines.

## Additional Notes

- Not sure if we really want to record the sign out event or not
- Might want to design breadcrumbs for nested setting pages
2024-02-02 09:42:25 +11:00
Sumit Bisht
ec3ba0e922 fix: active-tab changes correctly (#897)
fixes: #890
2024-02-02 08:30:02 +11:00
Apoorv Taneja
56683aa998 fix: Added signing pad disable state while submitting form (#892)
Fixes : #891
2024-02-01 19:14:37 +11:00
Lucas Smith
39be53ace8 fix: show fields on every step while editing documents (#881)
![CleanShot 2024-01-29 at 00 51
31@2x](https://github.com/documenso/documenso/assets/55143799/d577e027-92d1-48fa-940b-1359386367c5)

![CleanShot 2024-01-29 at 00 51
39@2x](https://github.com/documenso/documenso/assets/55143799/ce2df10e-e254-4854-89a1-ba86d7b05a42)
2024-02-01 12:55:31 +11:00
Lucas Smith
7fbf124b89 fix: use div instead of rnd for preview fields 2024-02-01 01:10:50 +00:00
David Nguyen
27d8098511 fix: document count period filter (#882)
## Description

Currently the count for the documents table tabs do not display the
correct values when the period filter is applied.

## Changes Made

- Updated `getStats` to support filtering on period

## Testing Performed

- Tested to see if the documents tab count were being filtered based on
the period

## Checklist

- [X] I have tested these changes locally and they work as expected.
- [X] I have followed the project's coding style guidelines.
2024-01-31 12:40:37 +11:00
David Nguyen
ada46a5f47 feat: add auth fail logs 2024-01-31 12:27:40 +11:00
David Nguyen
1bda74b3aa fix: add cascade delete for audit logs 2024-01-30 18:37:48 +11:00
David Nguyen
9427143951 fix: remove account create log 2024-01-30 18:26:46 +11:00
David Nguyen
7e15058a3a feat: add user security audit logs 2024-01-30 17:32:20 +11:00
Adithya Krishna
620ae41fcc feat: added password validation (#469)
This PR Fixes #464
2024-01-30 14:26:47 +11:00
Ephraim Atta-Duncan
f8125aec54 feat: show fields on other sections 2024-01-30 00:09:22 +00:00
Anik Dhabal Babu
9d6ee94708 chore: add title and description to individual pages (#847)
Add Title and Description to Individual Pages.
eg:- Security | Documenso, Profile | Documenso etc.
2024-01-29 17:53:44 +11:00
Lucas Smith
f3df0d9c13 fix: add env example crypto defaults back 2024-01-29 16:24:13 +11:00
Ephraim Duncan
a3a4480b03 Merge branch 'main' into fix/show-fields-subject 2024-01-29 01:40:49 +00:00
Ephraim Atta-Duncan
4af5ce3a6b chore: remove border color for field item 2024-01-29 01:38:44 +00:00
Ephraim Atta-Duncan
4ae19a9e63 chore: tidy code 2024-01-29 00:59:08 +00:00
Ephraim Atta-Duncan
6d5fe4eea3 fix: show the fields on the document at the subject selection page 2024-01-29 00:47:11 +00:00
Ephraim Duncan
354e16901c fix: sign dialog completed title color in dark mode (#879) 2024-01-29 11:08:31 +11:00
Ephraim Duncan
09aa10dad6 chore: rewording to avoid confusion between signed and original document (#880) 2024-01-29 11:04:57 +11:00
José Lima
671fd916b5 fix: resolve conflicting z-index values btwn avatar in document list and header (#872)
## Description

This pull request solves the problem where the avatar component within
the document list has the same z-index value as the header component,
causing the avatar to be above the header. When two elements have the
same z-index value, the last one takes priority!

## Related Issue
Fixes #870 

## Changes Made

1. Increases the value of the header's `z-index` by `10` (the current
value is `50`
2024-01-27 13:16:59 +11:00
Timur Ercan
a3ddbc15e9 Feat/commodifying signing (#874) 2024-01-26 12:36:33 +01:00
Timur Ercan
c7a04c7184 Merge branch 'main' into feat/commodifying-signing 2024-01-26 12:03:33 +01:00
Timur Ercan
8619e02d04 chore: quote fix 2024-01-26 12:02:30 +01:00
Timur Ercan
91c89e8bfb chore: quote fix 2024-01-26 12:01:53 +01:00
Timur Ercan
fdeab19a7f chore: fix paragh quote break 2024-01-26 12:00:00 +01:00
Timur Ercan
fd2a61f651 feat: commodifying signing (#865)
Adding the new blog article: Commodifying Signing
2024-01-25 17:01:30 +01:00
Timur Ercan
56f65f3bb3 chore: typos 2024-01-25 15:39:34 +01:00
Timur Ercan
75ad8a4885 chore: typos 2024-01-25 15:35:57 +01:00
Timur Ercan
db36f69273 Merge branch 'main' into feat/commodifying-signing 2024-01-25 15:26:25 +01:00
David Nguyen
d766b58f42 feat: add server crypto (#863)
## Description

Currently we are required to ensure PII data is not passed around in
search parameters and in the open for GDPR reasons.

Allowing us to encrypt and decrypt values with expiry dates will allow
us to ensure this doesn't happen.

## Changes Made

- Added TPRC router for encryption method

## Testing Performed

- Tested encrypting and decrypting data with and without `expiredAt`
- Tested via directly accessing API and also via trpc in react
components
- Tested parsing en email search param in a page and decrypting it
successfully

## Checklist

- [X] I have tested these changes locally and they work as expected.
- [X] I have followed the project's coding style guidelines.
2024-01-25 16:07:57 +11:00
David Nguyen
e90dd518df fix: auto verify google sso emails (#856) 2024-01-25 13:30:50 +11:00
Sumit Bisht
ee0af566a9 fix: correct document tab count for pending and completed (#855)
completed/pending status gets incremented once if sender is one of the
recipients

fixes #853
2024-01-25 11:29:04 +11:00
Surya Pratap Singh
11dd93451a feat: sign up with Google (#862)
This PR links to this issue: #791 
Now users can see a new option to sign up with Google on the signup
page.
2024-01-25 11:22:19 +11:00
Timur Ercan
2be022b9fc feat: commodofying signing blogpost 2024-01-24 18:01:26 +01:00
Timur Ercan
0fac7d7b70 chore: add tags to manifest 2024-01-24 16:52:38 +01:00
Apoorv Taneja
b115d85fb7 fix: disabled signing pad when submitting form (#842)
fixes : #810
2024-01-24 17:12:33 +11:00
Gautam Hegde
51d140cf9a feat: command group distinction (#854)
fixes #836 

- Explicit `div` is used instead of `<CommandSeparator/>` , since it
failed to render borders for dynamic search results, but only works for
initial menu.

(initial menu)

![cgrp](https://github.com/documenso/documenso/assets/85569489/0ee0aabb-c780-4c03-97e7-cf9905bb9b61)

(search results)

![dyanmic](https://github.com/documenso/documenso/assets/85569489/74b0a714-a952-4516-9787-53d50a60b78c)
2024-01-24 17:03:57 +11:00
Lucas Smith
caec2895cc chore: first small step to tracking growth mechanics (#859) 2024-01-24 14:03:16 +11:00
Anik Dhabal Babu
61967b22c1 fix: visibility of security fields using identityprovider (#709)
fixes #690
2024-01-24 11:34:30 +11:00
Adithya Krishna
8b8ca8578b chore: add adi to open page (#858)
name: Add addi to open page
2024-01-23 23:11:19 +05:30
Timur Ercan
576544344f chore: first small step to tracking growth mechanics 2024-01-23 16:20:25 +01:00
Timur Ercan
1efadb19f5 chore: add addi to open page 2024-01-23 15:54:57 +01:00
Lucas Smith
bc1d5cea0a fix: docker compose variable (#845)
Actually:

![Uz9ioXw](https://github.com/documenso/documenso/assets/26726263/553bb75a-a7fd-4707-9817-14a053ed75f4)
2024-01-23 19:50:49 +11:00
Anurag Sharma
6aed075c56 fix: add conditional rendering of OAuth providers (#736)
Now google OAuth provider is not rendered if client id is not provided
2024-01-23 17:08:48 +11:00
David Nguyen
e63122a718 chore: update github feature template (#849) 2024-01-23 11:28:11 +11:00
Catalin Pit
08011f9545 chore: added target blank for github link: (#851) 2024-01-23 11:27:10 +11:00
Mythie
4909eee401 feat: add viewing on completed page for pending documents 2024-01-22 21:36:46 +11:00
Mythie
e8c2ca8890 fix: mask documents in search 2024-01-22 12:32:19 +11:00
Tawagot0
9cc8bbdfc3 fix: docker compose variable 2024-01-20 17:45:59 +01:00
Lucas Smith
1191e1d9c3 feat: linear-gh blogpost (#827) 2024-01-20 12:18:23 +11:00
Lucas Smith
9c1e1f50a8 fix: mask recipient tokens for non-owners 2024-01-20 01:14:34 +00:00
Timur Ercan
efb9e9f3ec Merge branch 'main' into feature/linear-gh 2024-01-18 17:17:01 +01:00
Timur Ercan
a7672545d7 Update apps/marketing/content/blog/linear-gh.mdx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2024-01-18 17:16:49 +01:00
Timur Ercan
1a10cd2ae1 Update apps/marketing/content/blog/linear-gh.mdx
Co-authored-by: Adithya Krishna  <aadithya794@gmail.com>
2024-01-18 17:16:28 +01:00
Catalin Pit
204388888d fix: fix bug for completed document shortcut (#839)
When you're in the `/documents` page in the dashboard, if you hover over
a draft and a completed document, you'll see different URLs.

At the moment, the shortcut tries to go to the following URL for a
completed document `/documents/{doc-id}`.

However, that's the wrong URL, since the URL for a completed doc is
`/sign/{token}` when the user is the recipient, not the one that sent
the document for signing.

If it's the document owner & the document is completed, the URL is fine
as `/documents/{doc-id}`.

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2024-01-18 09:38:42 +02:00
Lucas Smith
0d977e783e refactor: download function to be reusable (#740) 2024-01-18 15:24:36 +11:00
Lucas Smith
0d15b80c2d fix: simplify code 2024-01-18 04:23:22 +00:00
Lucas Smith
4e9cce0df0 chore: improve request template (#833)
Ref #622 
Consolidated the template(removed redundant fields), added appropriate
validation.
2024-01-18 12:13:20 +11:00
Timur Ercan
6f726565e8 Update README.md
We are nominated for a Product Hunt Gold Kitty 😺 and appreciate any support: https://documen.so/kitty
2024-01-17 14:36:28 +01:00
Mythie
9ff44f10a6 chore: add incident blog post 2024-01-17 21:41:00 +11:00
Lucas Smith
16d97783f2 feat: improve the UX for password protected documents (#780) 2024-01-17 19:32:42 +11:00
Mythie
91dd10ec9b fix: add symmetric encryption to document passwords 2024-01-17 17:28:28 +11:00
Mythie
a94b829ee0 fix: tidy code 2024-01-17 17:17:08 +11:00
Fatuma Abdullahi
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
Gautam Hegde
b4b146ee49 Merge branch 'main' into Gautam-Hegde/issue#622 2024-01-16 23:27:34 +05:30
Gautam Hegde
67aebaac1a Update improvement.yml code quality 2024-01-16 01:14:48 +05:30
Gautam Hegde
a593e045b5 Update improvement.yml 2024-01-16 00:08:04 +05:30
harkiratsm
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
Timur Ercan
1a73f3e007 chore: feedback and phrasing 2024-01-11 14:27:44 +01:00
Timur Ercan
ea0120abc8 chore: typo 2024-01-10 16:51:42 +01:00
Timur Ercan
b501ffdee9 chore: images 2024-01-10 16:37:53 +01:00
Timur Ercan
31050d6b7b chore: spelling 2024-01-10 16:14:28 +01:00
Timur Ercan
ed1998278a feat: draft github blogpost 2024-01-10 16:14:02 +01:00
Harkirat Singh
eeb6a072aa Merge branch 'main' into harkirat/Protect 2024-01-10 10:45:19 +05:30
Adithya Krishna
d8cbe1d5ba Merge branch 'main' into harkirat/Protect 2024-01-03 11:34:42 +05:30
Ephraim Atta-Duncan
6a26ab4b2b fix: toast import errors 2024-01-02 04:52:15 +00:00
Ephraim Atta-Duncan
b76d2cea3b fix: changes from code review 2024-01-02 04:38:35 +00:00
harkiratsm
53c570151f fix lint, description of dialog
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2023-12-29 22:11:44 +05:30
harkiratsm
72a7dc6c05 fix the console error
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2023-12-29 17:26:33 +05:30
harkiratsm
2ae9e29903 feat: improve the ux for password protected documents
Signed-off-by: harkiratsm <multaniharry714@gmail.com>
2023-12-22 17:24:05 +05:30
David Nguyen
b9282f11b0 Merge branch 'main' into refactor/download-function 2023-12-08 11:26:02 +11:00
Ephraim Atta-Duncan
38ad3a1922 refactor: download function to be reusable 2023-12-07 14:52:12 +00:00
149 changed files with 2744 additions and 765 deletions

View File

@@ -4,8 +4,10 @@ NEXTAUTH_SECRET="secret"
# [[CRYPTO]] # [[CRYPTO]]
# Application Key for symmetric encryption and decryption # Application Key for symmetric encryption and decryption
# This should be a random string of at least 32 characters # REQUIRED: This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE" NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
# REQUIRED: This should be a random string of at least 32 characters
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
# [[AUTH OPTIONAL]] # [[AUTH OPTIONAL]]
NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
@@ -23,7 +25,7 @@ NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/
# [[E2E Tests]] # [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User" E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password" E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# [[STORAGE]] # [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
@@ -72,6 +74,8 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR= NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
# OPTIONAL: The private key to use for DKIM signing. # OPTIONAL: The private key to use for DKIM signing.
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY= NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
# OPTIONAL: Displays the maximum document upload limit to the user in MBs
NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
# [[STRIPE]] # [[STRIPE]]
NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_API_KEY=

View File

@@ -33,3 +33,4 @@ body:
- label: I have explained the use case or scenario for this feature. - label: I have explained the use case or scenario for this feature.
- label: I have included any relevant technical details or design suggestions. - label: I have included any relevant technical details or design suggestions.
- label: I understand that this is a suggestion and that there is no guarantee of implementation. - label: I understand that this is a suggestion and that there is no guarantee of implementation.
- label: I want to work on creating a PR for this issue if approved

View File

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

View File

@@ -1,3 +1,5 @@
>We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo"> <img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
<p align="center" style="margin-top: 20px"> <p align="center" style="margin-top: 20px">

View File

@@ -0,0 +1,87 @@
---
title: Commodifying Signing
description: We are creating signing as a public good and are commoditizing it to make it cheaper and better.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-01-25
Tags:
- Vision
- Mission
- Open Source
---
<figure>
<MdxNextImage
src="/blog/lighthouse.jpeg"
width="650"
height="650"
alt="A lighthouse on a tiny island."
/>
<figcaption className="text-center">
Lighthouses are often used as an example of a public good; As they benefit all maritime users, but no one can be excluded from using them as a navigational aid. Use by one person neither prevents access by other people, nor does it reduce availability to others.
</figcaption>
</figure>
# Commodifying Signing
> TLDR; We are creating signing as a public good and are commoditizing it to make it cheaper and better.
While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means.
Let's start with why we are doing this. Documenso's mission is to solve the domain of signing once and for all for everyone. In so many calls, I hear stories about how organizations build their own solution because the existing ones are too expensive or need to be more flexible. That means not hundreds but probably thousands of companies worldwide have done the same. This is simply wasting humanity's time. Since digital signing systems are understood well enough that seemingly "everyone" can build them, given enough pain, It's time to do it once correctly.
## Is signing already a commodity?
> In economics, a **commodity** is an economic good, usually a resource, that has explicitly full or substantial fungibility: that is, the market treats instances of the good as equivalent or nearly so with no regard to who produced them.
That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market?
- Signing is expensive and painful when you are locked into your vendor, and they charge by signing volume.
- Signing is also expensive and painful when you have to build it yourself since no vendor fits your requirements or you are not allowed to
To understand why, we need to look at the landscape as it is today:
- **Commodity**: Signing SaaS
- **Private Goods**: Signing Code Base, Regulatory Know-How
- **Public Goods**: Web Tech, Digital Signature Algorithms and Standards
What the current players have done is to commodify the listed public goods into commercial products:
> […]the action and process of transforming goods, services, ideas, nature, personal information, people, or animals into commodities.
> (Let's ignore the end of that list for now and what it says about humanity, yikes)
While this paradigm brought digital signing to many businesses worldwide, we aim for a different future. To solve signing once and for all, we need to achieve two core points:
- Making it cheaper so it's profitable for everyone to use
- Making it more accessible so everyone can use it (e.g. regulated industries) and flexible enough (extendable, open).
To achieve this, we must transform the landscape to look like this:
- **Commodities**: Enterprise Components, Support, Hosting, Self-Host Licenses
- **Public Goods**: (no longer private): OS (Open Source) Signing Code Base, OS Regulatory Know-How
- **Public Goods**: OS Web Tech, Digital Signature Algorithms and Standards
## Raising the Bar
Before creating a commodity, we are raising the bar of what the underlying public good is. Having an open source singing framework you can extend, self-host, and understand makes the resulting solution much more accessible and extendable for everyone. Now for the final feat of making signing cheaper:
As we have seen, signing has already been commodified. But since it was done by a closed source and, frankly, a very opaque industry, no downward price spiral has ensued. By building Documenso open source with an open culture, we can pierce the veil and trigger what the space has been missing for a long time: Commoditization. If you had to read that again, so did I:
> In business literature, **commoditization** is defined as the process by which goods that have economic value and are distinguishable in terms of attributes (uniqueness or brand) become simple commodities in the eyes of the market or consumers.
By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field, as described above, is the perfect environment for a community-first, technology-first, and value-first company like Documenso to flourish.
## Changing the Game
In this new world, a company needing signing (literally every company) can decide if the ROI (Return on Investment) of building signing themselves is greater than simply paying for the value-added activities they will need anyway. Pricing our offering not on volume but fixed is a nice additional wedge into the market we intend to use here.
The market dynamic now changes to who can offer the greatest value added to the public goods, driving the price down as this can be done much more efficiently than locking customers into closed source SaaS. Documenso, being a lean company, which we intend to stay with for a long time, will help kickstart this effect. Open Source capital efficiency is real. Our planned enterprise components, hosting support, and partner ecosystem will all leverage this effect.
We will grow our community around the public good, the open-source repo, and create an ecosystem around the commodities built on top of it (components, hosting, compliance, support). We will solve signing once and for all, and the world will be better for it. Onwards.
As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments.
Best from Hamburg\
Timur

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

@@ -0,0 +1,115 @@
---
title: Moving from Linear to GitHub & LIVE Roadmap 2.0
description: We are leaving linear and are going all in on GitHub. Here is how we do it.
authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-01-10
Tags:
- GitHub
- Backlog
- Roadmap
---
# From Linear to GitHub
> TLDR; We are leaving Linear and are using only GitHub going forward. We no longer communicate feature timelines, only what we are working on and what's next.
If you follow us, you know we have been in full-on build mode. We are building, the community is building, it's great. Building is our daily business, so we think a lot about improving our approach to doing it.
Our most recent approach is to reduce the number of tools and platforms we use. Every tool we use
- Reduces the average time you spend on the tool
- Reduces your focus
- Increases mental load to keep all points of interest in mind
We thought about where we spend the most time, and hardly surprising: it's GitHub. Not only do we spend a lot of time there, but we also WANT to spend a lot of time there because:
- It's where the community contributes, and we are all about community
- It's where we show the world what we are working on
# The old structure
So far, we have been using Linear for our Backlog/ Task Management and synced issues we want to showcase or work on with the community via synclinear.com. Not only did we have our development issues there, but since
we have our own resident founding designer, we created a proper design backlog to structure our design workflows.
# The new structure
We moved everything to GitHub once we realized our focus was already there. This has a few key benefits:
- Reducing dilution of attention and time: You can hang out on GitHub without risk of missing much
- Putting different aspects of Documenso close to each other: Development, Design, Community
- Keep long-term, niche, and very abstract issues out of the main repo so we don't get desensitized by large issue numbers
To achieve this, we created a few GitHub repositories to host issues, with the main repository remaining the central point of interest, especially for the community.
## 1. Main Repository - Day to day Issues and the shorter-term roadmap (LIVE Roadmap 2.0)
> [github.com/documenso/documenso](https://github.com/documenso/documenso)
Apart from the source code of the Documenso app and website, the main repo houses issues raised by the community and issues where we invite the community to participate.
With the overhauling of our issue management, we are also updating our progress communication. While the software and product development process is highly complex,
we try to give as much insight into what we do as possible. To that end, we went through 3 phases, three being what we do now.
1. **One extensive roadmap**: Initially we had one roadmap and were (very) slowly checking off boxes there (via a "Roadmap" milestone). While this is easy, it's also pretty imprecise and not practical as the project grows
2. **Estimated releases per quarter**: To give better guidance, we tried communicating our goals for the quarter; a pretty big window we thought we could roughly "hit". While the idea of not being too detailed was good, it is tough to estimate when some significant things are done if you do a lot of minor/ other things in parallel,
like working with the community and tuning things you go. Hitting time targets is tricky because there may be better things to do than sticking to that time target. This is always much easier to grasp for the people closely involved. The fallacy is to assume the thing you plan for exists in a vacuum.
3. Since we do not want to limit ourselves in choosing the most effective course but still give some insight into what's going on and what's coming up, we updated the live roadmap [https://documen.so/live](https://documen.so/live). It now shows what we are currently working on and what we plan on doing next. We do not provide
a specific timeline anymore since we couldn't even if we wanted to. Of course, we set our short-term goals based on what's best for the community. We give updates on the issues being worked on as well as possible.
## 2. Public Backlog - The longer-term roadmap
> [github.com/documenso/backlog](https://github.com/documenso/backlog)
The public backlog houses everything we want to build eventually. We do not provide a specific timeline of when that might happen. If we decide against something, it will be removed from the public backlog, as we consider this our long-term vision for Documenso. If you are interested in something on the roadmap, comment on the issue or post on Discord. This helps us gauge interest in specific features.
**Issues in the public backlog are not** available to be worked on. For issues to work on, please check the main repository issues. The issues found here are scoped broader since they are not meant for immediate execution but rather give a sense of where Documenso is going and what we consider part of our domain.
## 3. Internal Backlog
> github.com/documenso/backlog-internal
<figure>
<MdxNextImage
src="/blog/gh1.png"
width="1260"
height="630"
alt="GitHub: Development Board"
/>
<figcaption className="text-center">
Our internal Kanban for development
</figcaption>
</figure>
This serves as the direct replacement for our Linear backlog. Here, we manage issues that are either too small or short-term for inclusion in the long-term roadmap, yet too specialized or fundamental to be integrated into the main repository. Our development Kanban board is implemented using a GitHub project.
## 4. Internal Design Backlog
> github.com/documenso/design-internal
<figure>
<MdxNextImage
src="/blog/gh2.png"
width="1260"
height="630"
alt="GitHub: Design Board"
/>
<figcaption className="text-center">
Our internal Kanban for design
</figcaption>
</figure>
This is the design equivalent of the internal backlog. The internal design backlog houses our design projects that include the exploration of new features, detailed UI designs, and improving the platform overall.
It's similar to the Kanban board for the development backlog.
## 5. Public Design Repository
> [github.com/documenso/backlog-design](https://github.com/documenso/design)
While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead.
We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live).
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
Best from Hamburg\
Timur

View File

@@ -7,6 +7,8 @@ authorRole: 'Co-Founder'
date: 2023-07-13 date: 2023-07-13
tags: tags:
- Manifesto - Manifesto
- Open Source
- Vision
--- ---
<figure> <figure>

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

View File

@@ -15,7 +15,7 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
notFound(); notFound();
} }
return { title: `Documenso - ${document.title}` }; return { title: document.title };
}; };
const mdxComponents: MDXComponents = { const mdxComponents: MDXComponents = {

View File

@@ -18,7 +18,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
} }
return { return {
title: `Documenso - ${blogPost.title}`, title: {
absolute: `${blogPost.title} - Documenso Blog`,
},
description: blogPost.description, description: blogPost.description,
}; };
}; };

View File

@@ -1,5 +1,10 @@
import type { Metadata } from 'next';
import { allBlogPosts } from 'contentlayer/generated'; import { allBlogPosts } from 'contentlayer/generated';
export const metadata: Metadata = {
title: 'Blog',
};
export default function BlogPage() { export default function BlogPage() {
const blogPosts = allBlogPosts.sort((a, b) => { const blogPosts = allBlogPosts.sort((a, b) => {
const dateA = new Date(a.date); const dateA = new Date(a.date);

View File

@@ -47,6 +47,14 @@ export const TEAM_MEMBERS = [
engagement: 'Full-Time', engagement: 'Full-Time',
joinDate: 'October 9th, 2023', joinDate: 'October 9th, 2023',
}, },
{
name: 'Adithya Krishna',
role: 'Software Engineer - II',
salary: '-',
location: 'India',
engagement: 'Full-Time',
joinDate: 'December 1st, 2023',
},
]; ];
export const FUNDING_RAISED = [ export const FUNDING_RAISED = [

View File

@@ -1,3 +1,5 @@
import type { Metadata } from 'next';
import { z } from 'zod'; import { z } from 'zod';
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
@@ -14,6 +16,10 @@ import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
import { TeamMembers } from './team-members'; import { TeamMembers } from './team-members';
import { OpenPageTooltip } from './tooltip'; import { OpenPageTooltip } from './tooltip';
export const metadata: Metadata = {
title: 'Open Startup',
};
export const revalidate = 3600; export const revalidate = 3600;
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';

View File

@@ -1,3 +1,4 @@
import type { Metadata } from 'next';
import Image from 'next/image'; import Image from 'next/image';
import { z } from 'zod'; import { z } from 'zod';
@@ -5,7 +6,12 @@ import { z } from 'zod';
import backgroundPattern from '@documenso/assets/images/background-pattern.png'; import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { OSSFriendsContainer } from './container'; import { OSSFriendsContainer } from './container';
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema'; import type { TOSSFriendsSchema } from './schema';
import { ZOSSFriendsSchema } from './schema';
export const metadata: Metadata = {
title: 'OSS Friends',
};
export default async function OSSFriendsPage() { export default async function OSSFriendsPage() {
const ossFriends: TOSSFriendsSchema = await fetch('https://formbricks.com/api/oss-friends', { const ossFriends: TOSSFriendsSchema = await fetch('https://formbricks.com/api/oss-friends', {

View File

@@ -1,4 +1,5 @@
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ /* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
import type { Metadata } from 'next';
import { Caveat } from 'next/font/google'; import { Caveat } from 'next/font/google';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -10,6 +11,11 @@ import { OpenBuildTemplateBento } from '~/components/(marketing)/open-build-temp
import { ShareConnectPaidWidgetBento } from '~/components/(marketing)/share-connect-paid-widget-bento'; import { ShareConnectPaidWidgetBento } from '~/components/(marketing)/share-connect-paid-widget-bento';
export const revalidate = 600; export const revalidate = 600;
export const metadata: Metadata = {
title: {
absolute: 'Documenso - The Open Source DocuSign Alternative',
},
};
const fontCaveat = Caveat({ const fontCaveat = Caveat({
weight: ['500'], weight: ['500'],

View File

@@ -1,5 +1,4 @@
'use client'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { import {
@@ -12,6 +11,10 @@ import { Button } from '@documenso/ui/primitives/button';
import { PricingTable } from '~/components/(marketing)/pricing-table'; import { PricingTable } from '~/components/(marketing)/pricing-table';
export const metadata: Metadata = {
title: 'Pricing',
};
export type PricingPageProps = { export type PricingPageProps = {
searchParams?: { searchParams?: {
planId?: string; planId?: string;

View File

@@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
readStatus: 'OPENED', readStatus: 'OPENED',
signingStatus: 'NOT_SIGNED', signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT', sendStatus: 'NOT_SENT',
role: 'SIGNER',
}; };
const onFileDrop = async (file: File) => { const onFileDrop = async (file: File) => {

View File

@@ -1,5 +1,11 @@
import type { Metadata } from 'next';
import { SinglePlayerClient } from './client'; import { SinglePlayerClient } from './client';
export const metadata: Metadata = {
title: 'Singleplayer',
};
export const revalidate = 0; export const revalidate = 0;
// !: This entire file is a hack to get around failed prerendering of // !: This entire file is a hack to get around failed prerendering of

View File

@@ -18,7 +18,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = { export const metadata = {
title: 'Documenso - The Open Source DocuSign Alternative', title: {
template: '%s - Documenso',
default: 'Documenso',
},
description: description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.', 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords: keywords:

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useMemo, useState } from 'react';
import type { HTMLAttributes, KeyboardEvent } from 'react'; import type { HTMLAttributes, KeyboardEvent } from 'react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
@@ -355,7 +355,6 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<div <div
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2" className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
> >
<Input <Input
id="signatureText" id="signatureText"
@@ -393,14 +392,14 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
By signing you signal your support of Documenso's mission in a <br /> By signing you signal your support of Documenso's mission in a <br></br>
<strong>non-legally binding, but heartfelt way</strong>. <br /> <strong>non-legally binding, but heartfelt way</strong>. <br></br>
<br /> <br></br>You also unlock the option to purchase the early supporter plan including
You also unlock the option to purchase the early supporter plan including everything we everything we build this year for fixed price.
build this year for fixed price.
</DialogDescription> </DialogDescription>
<SignaturePad <SignaturePad
disabled={isSubmitting}
className="aspect-video w-full rounded-md border" className="aspect-video w-full rounded-md border"
defaultValue={signatureDataUrl || ''} defaultValue={signatureDataUrl || ''}
onChange={setDraftSignatureDataUrl} onChange={setDraftSignatureDataUrl}

View File

@@ -17,10 +17,6 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'), path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
); );
const FONT_DANCING_SCRIPT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/dancing-script.ttf'),
);
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const config = { const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined, output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
@@ -44,7 +40,6 @@ const config = {
APP_VERSION: version, APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web', NEXT_PUBLIC_PROJECT: 'web',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`, FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
FONT_DANCING_SCRIPT_URI: `data:font/ttf;base64,${FONT_DANCING_SCRIPT_BYTES.toString('base64')}`,
}, },
modularizeImports: { modularizeImports: {
'lucide-react': { 'lucide-react': {

View File

@@ -45,6 +45,7 @@
"sharp": "0.33.1", "sharp": "0.33.1",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"typescript": "5.2.2", "typescript": "5.2.2",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2", "uqr": "^0.1.2",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
@@ -53,7 +54,8 @@
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@types/node": "20.1.0", "@types/node": "20.1.0",
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7" "@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39"
}, },
"overrides": { "overrides": {
"next-auth": { "next-auth": {

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client'; import type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@@ -29,6 +29,7 @@ export type EditDocumentFormProps = {
user: User; user: User;
document: DocumentWithData; document: DocumentWithData;
recipients: Recipient[]; recipients: Recipient[];
documentMeta: DocumentMeta | null;
fields: Field[]; fields: Field[];
documentData: DocumentData; documentData: DocumentData;
}; };
@@ -41,6 +42,7 @@ export const EditDocumentForm = ({
document, document,
recipients, recipients,
fields, fields,
documentMeta,
user: _user, user: _user,
documentData, documentData,
}: EditDocumentFormProps) => { }: EditDocumentFormProps) => {
@@ -56,6 +58,8 @@ export const EditDocumentForm = ({
const { mutateAsync: addFields } = trpc.field.addFields.useMutation(); const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation(); const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation(); const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation();
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = { const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
title: { title: {
@@ -176,6 +180,13 @@ export const EditDocumentForm = ({
} }
}; };
const onPasswordSubmit = async (password: string) => {
await setPasswordForDocument({
documentId: document.id,
password,
});
};
const currentDocumentFlow = documentFlow[step]; const currentDocumentFlow = documentFlow[step];
return ( return (
@@ -185,7 +196,13 @@ export const EditDocumentForm = ({
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} /> <LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
onPasswordSubmit={onPasswordSubmit}
/>
</CardContent> </CardContent>
</Card> </Card>
@@ -201,9 +218,9 @@ export const EditDocumentForm = ({
<AddTitleFormPartial <AddTitleFormPartial
key={recipients.length} key={recipients.length}
documentFlow={documentFlow.title} documentFlow={documentFlow.title}
document={document}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
document={document}
onSubmit={onAddTitleFormSubmit} onSubmit={onAddTitleFormSubmit}
/> />

View File

@@ -3,10 +3,12 @@ import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react'; 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 { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; 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 { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-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 { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@@ -40,7 +42,24 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
redirect('/documents'); 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([ const [recipients, fields] = await Promise.all([
getRecipientsForDocument({ getRecipientsForDocument({
@@ -83,6 +102,7 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
className="mt-8" className="mt-8"
document={document} document={document}
user={user} user={user}
documentMeta={documentMeta}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
documentData={documentData} documentData={documentData}
@@ -91,7 +111,12 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
{document.status === InternalDocumentStatus.COMPLETED && ( {document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl"> <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>
)} )}
</div> </div>

View File

@@ -2,13 +2,13 @@
import Link from 'next/link'; import Link from 'next/link';
import { Download, Edit, Pencil } from 'lucide-react'; import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { Document, Recipient, User } from '@documenso/prisma/client'; import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -37,6 +37,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isPending = row.status === DocumentStatus.PENDING; const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
const onDownloadClick = async () => { const onDownloadClick = async () => {
try { try {
@@ -55,33 +56,24 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const documentData = document?.documentData; const documentData = document?.documentData;
if (!documentData) { if (!documentData) {
return; throw Error('No document available');
} }
const documentBytes = await getFile(documentData); await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob);
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
} catch (error) {
toast({ toast({
title: 'Something went wrong', title: 'Something went wrong',
description: 'An error occurred while trying to download file.', description: 'An error occurred while downloading your document.',
variant: 'destructive', variant: 'destructive',
}); });
} }
}; };
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null;
}
return match({ return match({
isOwner, isOwner,
isRecipient, isRecipient,
@@ -101,15 +93,32 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
.with({ isRecipient: true, isPending: true, isSigned: false }, () => ( .with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild> <Button className="w-32" asChild>
<Link href={`/sign/${recipient?.token}`}> <Link href={`/sign/${recipient?.token}`}>
<Pencil className="-ml-1 mr-2 h-4 w-4" /> {match(role)
Sign .with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</>
))}
</Link> </Link>
</Button> </Button>
)) ))
.with({ isPending: true, isSigned: true }, () => ( .with({ isPending: true, isSigned: true }, () => (
<Button className="w-32" disabled={true}> <Button className="w-32" disabled={true}>
<Pencil className="-ml-1 mr-2 inline h-4 w-4" /> <EyeIcon className="-ml-1 mr-2 h-4 w-4" />
Sign View
</Button> </Button>
)) ))
.with({ isComplete: true }, () => ( .with({ isComplete: true }, () => (

View File

@@ -5,9 +5,11 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { import {
CheckCircle,
Copy, Copy,
Download, Download,
Edit, Edit,
EyeIcon,
Loader, Loader,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
@@ -17,9 +19,9 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { Document, Recipient, User } from '@documenso/prisma/client'; import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@@ -30,6 +32,7 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu'; } from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ResendDocumentActionItem } from './_action-items/resend-document'; import { ResendDocumentActionItem } from './_action-items/resend-document';
import { DeleteDocumentDialog } from './delete-document-dialog'; import { DeleteDocumentDialog } from './delete-document-dialog';
@@ -44,6 +47,7 @@ export type DataTableActionDropdownProps = {
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const { toast } = useToast();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
@@ -63,39 +67,33 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
const isDocumentDeletable = isOwner; const isDocumentDeletable = isOwner;
const onDownloadClick = async () => { const onDownloadClick = async () => {
let document: DocumentWithData | null = null; try {
let document: DocumentWithData | null = null;
if (!recipient) { if (!recipient) {
document = await trpcClient.document.getDocumentById.query({ document = await trpcClient.document.getDocumentById.query({
id: row.id, id: row.id,
}); });
} else { } else {
document = await trpcClient.document.getDocumentByToken.query({ document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token, token: recipient.token,
});
}
const documentData = document?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
}); });
} }
const documentData = document?.documentData;
if (!documentData) {
return;
}
const documentBytes = await getFile(documentData);
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob);
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
}; };
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
@@ -109,12 +107,32 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuContent className="w-52" align="start" forceMount> <DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel> <DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!recipient || isComplete} asChild> {recipient?.role !== RecipientRole.CC && (
<Link href={`/sign/${recipient?.token}`}> <DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Pencil className="mr-2 h-4 w-4" /> <Link href={`/sign/${recipient?.token}`}>
Sign {recipient?.role === RecipientRole.VIEWER && (
</Link> <>
</DropdownMenuItem> <EyeIcon className="mr-2 h-4 w-4" />
View
</>
)}
{recipient?.role === RecipientRole.SIGNER && (
<>
<Pencil className="mr-2 h-4 w-4" />
Sign
</>
)}
{recipient?.role === RecipientRole.APPROVER && (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</>
)}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={!isOwner || isComplete} asChild> <DropdownMenuItem disabled={!isOwner || isComplete} asChild>
<Link href={`/documents/${row.id}`}> <Link href={`/documents/${row.id}`}>

View File

@@ -1,6 +1,8 @@
import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats'; import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
@@ -8,7 +10,6 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector'; import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status'; import { DocumentStatus } from '~/components/formatter/document-status';
@@ -25,18 +26,22 @@ export type DocumentsPageProps = {
}; };
}; };
export const metadata: Metadata = {
title: 'Documents',
};
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) { export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();
const stats = await getStats({
user,
});
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL'; const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : ''; const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1; const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20; const perPage = Number(searchParams.perPage) || 20;
const stats = await getStats({
user,
period,
});
const results = await findDocuments({ const results = await findDocuments({
userId: user.id, userId: user.id,
status, status,
@@ -69,7 +74,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
<h1 className="text-4xl font-semibold">Documents</h1> <h1 className="text-4xl font-semibold">Documents</h1>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1"> <div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs defaultValue={status} className="overflow-x-auto"> <Tabs value={status} className="overflow-x-auto">
<TabsList> <TabsList>
{[ {[
ExtendedDocumentStatus.INBOX, ExtendedDocumentStatus.INBOX,
@@ -88,7 +93,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
<DocumentStatus status={value} /> <DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && ( {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)} {Math.min(stats[value], 99)}
{stats[value] > 99 && '+'} {stats[value] > 99 && '+'}
</span> </span>

View File

@@ -10,6 +10,7 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
@@ -96,6 +97,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
} }
}; };
const onFileDropRejected = () => {
toast({
title: 'Your document failed to upload.',
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
duration: 5000,
variant: 'destructive',
});
};
return ( return (
<div className={cn('relative', className)}> <div className={cn('relative', className)}>
<DocumentDropzone <DocumentDropzone
@@ -103,6 +113,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
disabled={remaining.documents === 0 || !session?.user.emailVerified} disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage} disabledMessage={disabledMessage}
onDrop={onFileDrop} onDrop={onFileDrop}
onDropRejected={onFileDropRejected}
/> />
<div className="absolute -bottom-6 right-0"> <div className="absolute -bottom-6 right-0">

View File

@@ -1,3 +1,4 @@
import type { Metadata } from 'next';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@@ -17,6 +18,10 @@ import { LocaleDate } from '~/components/formatter/locale-date';
import { BillingPlans } from './billing-plans'; import { BillingPlans } from './billing-plans';
import { BillingPortalButton } from './billing-portal-button'; import { BillingPortalButton } from './billing-portal-button';
export const metadata: Metadata = {
title: 'Billing',
};
export default async function BillingSettingsPage() { export default async function BillingSettingsPage() {
let { user } = await getRequiredServerComponentSession(); let { user } = await getRequiredServerComponentSession();

View File

@@ -1,7 +1,13 @@
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { ProfileForm } from '~/components/forms/profile'; import { ProfileForm } from '~/components/forms/profile';
export const metadata: Metadata = {
title: 'Profile',
};
export default async function ProfileSettingsPage() { export default async function ProfileSettingsPage() {
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();

View File

@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
export const metadata: Metadata = {
title: 'Security activity',
};
export default function SettingsSecurityActivityPage() {
return (
<div>
<h3 className="text-2xl font-semibold">Security activity</h3>
<p className="text-muted-foreground mt-2 text-sm">
View all recent security activity related to your account.
</p>
<hr className="my-4" />
<UserSecurityActivityDataTable />
</div>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { UAParser } from 'ua-parser-js';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { LocaleDate } from '~/components/formatter/locale-date';
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
export const UserSecurityActivityDataTable = () => {
const parser = new UAParser();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isInitialLoading, isLoadingError } =
trpc.profile.findUserSecurityAuditLogs.useQuery(
{
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
},
{
keepPreviousData: true,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<DataTable
columns={[
{
header: 'Date',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
},
{
header: 'Device',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
let output = result.os.name;
if (!output) {
return 'N/A';
}
if (result.os.version) {
output += ` (${result.os.version})`;
}
return output;
},
},
{
header: 'Browser',
cell: ({ row }) => {
if (!row.original.userAgent) {
return 'N/A';
}
parser.setUA(row.original.userAgent);
const result = parser.getResult();
return result.browser.name ?? 'N/A';
},
},
{
header: 'IP Address',
accessorKey: 'ipAddress',
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
},
{
header: 'Action',
accessorKey: 'type',
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
},
]}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
onClearFilters={() => router.push(pathname ?? '/')}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading && isInitialLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination table={table} />}
</DataTable>
);
};

View File

@@ -1,9 +1,19 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app'; import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes'; import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
import { PasswordForm } from '~/components/forms/password'; import { PasswordForm } from '~/components/forms/password';
export const metadata: Metadata = {
title: 'Security',
};
export default async function SecuritySettingsPage() { export default async function SecuritySettingsPage() {
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();
@@ -17,30 +27,76 @@ export default async function SecuritySettingsPage() {
<hr className="my-4" /> <hr className="my-4" />
<PasswordForm user={user} className="max-w-xl" /> {user.identityProvider === 'DOCUMENSO' ? (
<div>
<PasswordForm user={user} />
<hr className="mb-4 mt-8" /> <hr className="border-border/50 mt-6" />
<h4 className="text-lg font-medium">Two Factor Authentication</h4> <Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Two factor authentication</AlertTitle>
<p className="text-muted-foreground mt-2 text-sm"> <AlertDescription className="mr-4">
Add and manage your two factor security settings to add an extra layer of security to your Create one-time passwords that serve as a secondary authentication method for
account! confirming your identity when requested during the sign-in process.
</p> </AlertDescription>
</div>
<div className="mt-4 max-w-xl"> <AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
<h5 className="font-medium">Two-factor methods</h5> </Alert>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} /> {user.twoFactorEnabled && (
</div> <Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Recovery codes</AlertTitle>
{user.twoFactorEnabled && ( <AlertDescription className="mr-4">
<div className="mt-4 max-w-xl"> Two factor authentication recovery codes are used to access your account in the
<h5 className="font-medium">Recovery methods</h5> event that you lose access to your authenticator app.
</AlertDescription>
</div>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} /> <RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
</Alert>
)}
</div> </div>
) : (
<Alert className="p-6" variant="neutral">
<AlertTitle>
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
</AlertTitle>
<AlertDescription>
To update your password, enable two-factor authentication, and manage other security
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
settings.
</AlertDescription>
</Alert>
)} )}
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>Recent activity</AlertTitle>
<AlertDescription className="mr-2">
View all recent security activity related to your account.
</AlertDescription>
</div>
<Button asChild>
<Link href="/settings/security/activity">View activity</Link>
</Button>
</Alert>
</div> </div>
); );
} }

View File

@@ -2,13 +2,16 @@
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader, Plus } from 'lucide-react'; import { AlertTriangle, Loader, Plus } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Template } from '@documenso/prisma/client'; import type { Template } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -36,6 +39,8 @@ export const TemplatesDataTable = ({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams(); const updateSearchParams = useUpdateSearchParams();
const { remaining } = useLimits();
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
@@ -77,6 +82,19 @@ export const TemplatesDataTable = ({
return ( return (
<div className="relative"> <div className="relative">
{remaining.documents === 0 && (
<Alert className="mb-4 mt-5">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Document Limit Exceeded!</AlertTitle>
<AlertDescription className="mt-2">
You have reached your document limit.{' '}
<Link className="underline underline-offset-4" href="/settings/billing">
Upgrade your account to continue!
</Link>
</AlertDescription>
</Alert>
)}
<DataTable <DataTable
columns={[ columns={[
{ {
@@ -102,7 +120,7 @@ export const TemplatesDataTable = ({
return ( return (
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<Button <Button
disabled={isRowLoading} disabled={isRowLoading || remaining.documents === 0}
loading={isRowLoading} loading={isRowLoading}
onClick={async () => { onClick={async () => {
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true })); setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));

View File

@@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplates } from '@documenso/lib/server-only/template/get-templates'; import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
@@ -14,6 +16,10 @@ type TemplatesPageProps = {
}; };
}; };
export const metadata: Metadata = {
title: 'Templates',
};
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) { export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();
const page = Number(searchParams.page) || 1; const page = Number(searchParams.page) || 1;

View File

@@ -0,0 +1,39 @@
'use client';
import { useState } from 'react';
import { FileSearch } from 'lucide-react';
import type { DocumentData } from '@documenso/prisma/client';
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import type { ButtonProps } from '@documenso/ui/primitives/button';
import { Button } from '@documenso/ui/primitives/button';
export type DocumentPreviewButtonProps = {
className?: string;
documentData: DocumentData;
} & ButtonProps;
export const DocumentPreviewButton = ({
className,
documentData,
...props
}: DocumentPreviewButtonProps) => {
const [showDialog, setShowDialog] = useState(false);
return (
<>
<Button
className={className}
variant="outline"
onClick={() => setShowDialog((visible) => !visible)}
{...props}
>
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
View Original Document
</Button>
<DocumentDialog documentData={documentData} open={showDialog} onOpenChange={setShowDialog} />
</>
);
};

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
export type SigningLayoutProps = {
children: React.ReactNode;
};
export default function SigningLayout({ children }: SigningLayoutProps) {
return (
<div>
{children}
<RefreshOnFocus />
</div>
);
}

View File

@@ -10,13 +10,15 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { DocumentStatus, FieldType } from '@documenso/prisma/client'; import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { truncateTitle } from '~/helpers/truncate-title'; import { truncateTitle } from '~/helpers/truncate-title';
import { DocumentPreviewButton } from './document-preview-button';
export type CompletedSigningPageProps = { export type CompletedSigningPageProps = {
params: { params: {
token?: string; token?: string;
@@ -92,7 +94,10 @@ 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"> <h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have signed You have
{recipient.role === RecipientRole.SIGNER && ' signed '}
{recipient.role === RecipientRole.VIEWER && ' viewed '}
{recipient.role === RecipientRole.APPROVER && ' approved '}
<span className="mt-1.5 block">"{truncatedTitle}"</span> <span className="mt-1.5 block">"{truncatedTitle}"</span>
</h2> </h2>
@@ -117,12 +122,20 @@ export default async function CompletedSigningPage({
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4"> <div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<DocumentShareButton documentId={document.id} token={recipient.token} /> <DocumentShareButton documentId={document.id} token={recipient.token} />
<DocumentDownloadButton {document.status === DocumentStatus.COMPLETED ? (
className="flex-1" <DocumentDownloadButton
fileName={document.title} className="flex-1"
documentData={documentData} fileName={document.title}
disabled={document.status !== DocumentStatus.COMPLETED} documentData={documentData}
/> disabled={document.status !== DocumentStatus.COMPLETED}
/>
) : (
<DocumentPreviewButton
className="text-[11px]"
title="Signatures will appear once the document has been completed"
documentData={documentData}
/>
)}
</div> </div>
{isLoggedIn ? ( {isLoggedIn ? (

View File

@@ -4,20 +4,12 @@ import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { 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'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
MIN_HANDWRITING_FONT_SIZE,
} from '@documenso/lib/constants/pdf';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Document, Field, Recipient } from '@documenso/prisma/client'; import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -36,27 +28,7 @@ export type SigningFormProps = {
fields: Field[]; fields: Field[];
}; };
const ZSigningpadSchema = z.union([ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
z.object({
signatureDataUrl: z.string().min(1),
signatureText: z.null().or(z.string().max(0)),
}),
z.object({
signatureDataUrl: z.null().or(z.string().max(0)),
signatureText: z.string().trim().min(1),
}),
]);
export type TSigningpadSchema = z.infer<typeof ZSigningpadSchema>;
export const SigningForm = ({ document: _document, recipient, fields }: SigningFormProps) => {
const fontVariable = '--font-signature';
const minFontSize = MIN_HANDWRITING_FONT_SIZE;
const maxFontSize = DEFAULT_HANDWRITING_FONT_SIZE;
const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue(
fontVariable,
);
const router = useRouter(); const router = useRouter();
const analytics = useAnalytics(); const analytics = useAnalytics();
const { data: session } = useSession(); const { data: session } = useSession();
@@ -69,24 +41,9 @@ export const SigningForm = ({ document: _document, recipient, fields }: SigningF
trpc.recipient.completeDocumentWithToken.useMutation(); trpc.recipient.completeDocumentWithToken.useMutation();
const { const {
register,
handleSubmit, handleSubmit,
setValue,
watch,
formState: { isSubmitting }, formState: { isSubmitting },
} = useForm<TSigningpadSchema>({ } = useForm();
mode: 'onChange',
defaultValues: {
signatureDataUrl: signature || null,
signatureText: '',
},
resolver: zodResolver(ZSigningpadSchema),
});
const { height, width } = useFieldPageCoords(fields.find((field) => field.type === 'SIGNATURE')!);
const signatureDataUrl = watch('signatureDataUrl');
const signatureText = watch('signatureText');
const uninsertedFields = useMemo(() => { const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fields.filter((field) => !field.inserted)); return sortFieldsByPosition(fields.filter((field) => !field.inserted));
@@ -108,30 +65,18 @@ export const SigningForm = ({ document: _document, recipient, fields }: SigningF
await completeDocumentWithToken({ await completeDocumentWithToken({
token: recipient.token, token: recipient.token,
documentId: _document.id, documentId: document.id,
}); });
analytics.capture('App: Recipient has completed signing', { analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id, signerId: recipient.id,
documentId: _document.id, documentId: document.id,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
router.push(`/sign/${recipient.token}/complete`); router.push(`/sign/${recipient.token}/complete`);
}; };
const scalingFactor = useElementScaleSize(
{
height,
width,
},
signatureText || '',
maxFontSize,
fontVariableValue,
);
const fontSize = maxFontSize * scalingFactor;
return ( return (
<form <form
className={cn( className={cn(
@@ -151,137 +96,114 @@ export const SigningForm = ({ document: _document, recipient, fields }: SigningF
<fieldset <fieldset
disabled={isSubmitting} disabled={isSubmitting}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')} className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
> >
<div <div className={cn('flex flex-1 flex-col')}>
className={cn( <h3 className="text-foreground text-2xl font-semibold">
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2', {recipient.role === RecipientRole.VIEWER && 'View Document'}
)} {recipient.role === RecipientRole.SIGNER && 'Sign Document'}
> {recipient.role === RecipientRole.APPROVER && 'Approve Document'}
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3> </h3>
<p className="text-muted-foreground mt-2 text-sm"> {recipient.role === RecipientRole.VIEWER ? (
Please review the document before signing. <>
</p> <p className="text-muted-foreground mt-2 text-sm">
Please mark as viewed to complete
</p>
<hr className="border-border mb-8 mt-4" /> <hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"> <div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4"> <div className="flex flex-1 flex-col gap-y-4" />
<div> <div className="flex flex-col gap-4 md:flex-row">
<Label htmlFor="full-name">Full Name</Label> <Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<Input <SignDialog
type="text" isSubmitting={isSubmitting}
id="full-name" onSignatureComplete={handleSubmit(onFormSubmit)}
className="bg-background mt-2" document={document}
value={fullName} fields={fields}
onChange={(e) => setFullName(e.target.value.trimStart())} fieldsValidated={fieldsValidated}
/> role={recipient.role}
/>
</div>
</div> </div>
</>
) : (
<>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
</p>
<div> <hr className="border-border mb-8 mt-4" />
<Label htmlFor="Signature">Signature</Label>
<Card id="signature" className="mt-4" degrees={-120} gradient> <div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<CardContent role="button" className="relative cursor-pointer pt-6"> <div className="flex flex-1 flex-col gap-y-4">
<div className="flex h-44 max-w-[18rem] items-center justify-center pb-6"> <div>
{!signatureText && ( <Label htmlFor="full-name">Full Name</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>
<div>
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}>
<CardContent className="p-0">
<SignaturePad <SignaturePad
className="h-44" className="h-44 w-full"
disabled={isSubmitting}
defaultValue={signature ?? undefined} defaultValue={signature ?? undefined}
clearSignatureClassName="absolute -bottom-6 -right-2 z-10 cursor-pointer"
undoSignatureClassName="absolute -top-32 -left-4 z-10 cursor-pointer"
onChange={(value) => { onChange={(value) => {
setSignature(value); setSignature(value);
}} }}
/> />
)} </CardContent>
</Card>
</div>
</div>
{signatureText && ( <div className="flex flex-col gap-4 md:flex-row">
<p <Button
style={{ type="button"
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`, className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
fontFamily: `var(${fontVariable})`, variant="secondary"
}} size="lg"
className={cn( disabled={typeof window !== 'undefined' && window.history.length <= 1}
'text-foreground font-signature max-w-[18rem] text-4xl font-semibold', onClick={() => router.back()}
)} >
> Cancel
{signatureText} </Button>
</p>
)}
</div>
<div <SignDialog
className="absolute inset-x-0 bottom-0 flex cursor-auto items-end justify-between px-4 pb-1 pt-2" isSubmitting={isSubmitting}
onClick={(e) => e.stopPropagation()} onSignatureComplete={handleSubmit(onFormSubmit)}
onKeyDown={(e) => e.stopPropagation()} document={document}
> fields={fields}
<Input fieldsValidated={fieldsValidated}
id="signatureText" role={recipient.role}
className="text-foreground placeholder:text-muted-foreground max-w-[15rem] border-0 border-none bg-transparent p-0 text-sm focus-visible:ring-transparent" />
placeholder="Draw or type your name here" </div>
disabled={isSubmitting || signature?.startsWith('data:')}
{...register('signatureText', {
onChange: (e) => {
if (e.target.value !== '') {
setValue('signatureDataUrl', null);
}
setValue('signatureText', e.target.value);
},
onBlur: (e) => {
if (e.target.value === '') {
return setValue('signatureText', '');
}
setSignature(e.target.value.trimStart());
},
})}
/>
{signatureText && (
<div className="absolute bottom-3 right-4 z-10 cursor-pointer">
<button
type="button"
className="focus-visible:ring-ring ring-offset-background text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2"
onClick={() => {
setValue('signatureText', '');
setValue('signatureDataUrl', null);
}}
>
Clear Signature
</button>
</div>
)}
</div>
</CardContent>
</Card>
</div> </div>
</div> </>
)}
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={_document}
fields={fields}
fieldsValidated={fieldsValidated}
/>
</div>
</div>
</div> </div>
</fieldset> </fieldset>
</form> </form>

View File

@@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern'; 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 { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
@@ -12,7 +13,8 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@@ -66,6 +68,23 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
redirect(`/sign/${token}/complete`); 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 }); const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
if (document.deletedAt) { if (document.deletedAt) {
@@ -91,7 +110,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
<div className="mt-2.5 flex items-center gap-x-6"> <div className="mt-2.5 flex items-center gap-x-6">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{document.User.name} ({document.User.email}) has invited you to sign this document. {document.User.name} ({document.User.email}) has invited you to{' '}
{recipient.role === RecipientRole.VIEWER && 'view'}
{recipient.role === RecipientRole.SIGNER && 'sign'}
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
</p> </p>
</div> </div>
@@ -101,7 +123,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
gradient gradient
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} /> <LazyPDFViewer
key={documentData.id}
documentData={documentData}
document={document}
password={documentMeta?.password}
/>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Document, Field } from '@documenso/prisma/client'; import type { Document, Field } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@@ -17,6 +18,7 @@ export type SignDialogProps = {
fields: Field[]; fields: Field[];
fieldsValidated: () => void | Promise<void>; fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>; onSignatureComplete: () => void | Promise<void>;
role: RecipientRole;
}; };
export const SignDialog = ({ export const SignDialog = ({
@@ -25,6 +27,7 @@ export const SignDialog = ({
fields, fields,
fieldsValidated, fieldsValidated,
onSignatureComplete, onSignatureComplete,
role,
}: SignDialogProps) => { }: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title); const truncatedTitle = truncateTitle(document.title);
@@ -45,9 +48,18 @@ export const SignDialog = ({
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<div className="text-center"> <div className="text-center">
<div className="text-xl font-semibold text-neutral-800">Sign Document</div> <div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
{role === RecipientRole.SIGNER && 'Sign Document'}
{role === RecipientRole.APPROVER && 'Approve Document'}
</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center"> <div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
You are about to finish signing "{truncatedTitle}". Are you sure? {role === RecipientRole.VIEWER &&
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.SIGNER &&
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.APPROVER &&
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
</div> </div>
</div> </div>
@@ -71,7 +83,9 @@ export const SignDialog = ({
loading={isSubmitting} loading={isSubmitting}
onClick={onSignatureComplete} onClick={onSignatureComplete}
> >
Sign {role === RecipientRole.VIEWER && 'Mark as Viewed'}
{role === RecipientRole.SIGNER && 'Sign'}
{role === RecipientRole.APPROVER && 'Approve'}
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>

View File

@@ -86,7 +86,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
value, value,
isBase64: typeof value === 'string' && value.startsWith('data:image/png;base64,'), isBase64: true,
}); });
if (source === 'local' && !providedSignature) { if (source === 'local' && !providedSignature) {

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
export const metadata: Metadata = {
title: 'Forgot password',
};
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
return ( return (
<div> <div>

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { ForgotPasswordForm } from '~/components/forms/forgot-password'; import { ForgotPasswordForm } from '~/components/forms/forgot-password';
export const metadata: Metadata = {
title: 'Forgot Password',
};
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
return ( return (
<div> <div>

View File

@@ -1,7 +1,12 @@
import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
export const metadata: Metadata = {
title: 'Reset Password',
};
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
return ( return (
<div> <div>

View File

@@ -1,7 +1,14 @@
import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { SignInForm } from '~/components/forms/signin'; import { SignInForm } from '~/components/forms/signin';
export const metadata: Metadata = {
title: 'Sign In',
};
export default function SignInPage() { export default function SignInPage() {
return ( return (
<div> <div>
@@ -11,7 +18,7 @@ export default function SignInPage() {
Welcome back, we are lucky to have you. Welcome back, we are lucky to have you.
</p> </p>
<SignInForm className="mt-4" /> <SignInForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && ( {process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm"> <p className="text-muted-foreground mt-6 text-center text-sm">

View File

@@ -1,8 +1,15 @@
import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { SignUpForm } from '~/components/forms/signup'; import { SignUpForm } from '~/components/forms/signup';
export const metadata: Metadata = {
title: 'Sign Up',
};
export default function SignUpPage() { export default function SignUpPage() {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
redirect('/signin'); redirect('/signin');
@@ -17,7 +24,7 @@ export default function SignUpPage() {
signing is within your grasp. signing is within your grasp.
</p> </p>
<SignUpForm className="mt-4" /> <SignUpForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
<p className="text-muted-foreground mt-6 text-center text-sm"> <p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '} Already have an account?{' '}

View File

@@ -1,9 +1,14 @@
import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { XCircle } from 'lucide-react'; import { XCircle } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
export const metadata: Metadata = {
title: 'Verify Email',
};
export default function EmailVerificationWithoutTokenPage() { export default function EmailVerificationWithoutTokenPage() {
return ( return (
<div className="flex w-full items-start"> <div className="flex w-full items-start">

View File

@@ -20,7 +20,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = { export const metadata = {
title: 'Documenso - The Open Source DocuSign Alternative', title: {
template: '%s - Documenso',
default: 'Documenso',
},
description: description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.', 'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords: keywords:

View File

@@ -4,8 +4,10 @@ import React from 'react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from './stack-avatar'; import { StackAvatar } from './stack-avatar';
@@ -19,6 +21,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
const { toast } = useToast(); const { toast } = useToast();
const onRecipientClick = () => { const onRecipientClick = () => {
if (!recipient.token) {
return;
}
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => { void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
toast({ toast({
title: 'Copied to clipboard', title: 'Copied to clipboard',
@@ -28,19 +34,31 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
}; };
return ( return (
<div className="my-1 flex cursor-pointer items-center gap-2" onClick={onRecipientClick}> <div
className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': recipient.token,
})}
role={recipient.token ? 'button' : undefined}
title={recipient.token && 'Click to copy signing link for sending to recipient'}
onClick={onRecipientClick}
>
<StackAvatar <StackAvatar
first={true} first={true}
key={recipient.id} key={recipient.id}
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)} fallbackText={recipientAbbreviation(recipient)}
/> />
<span <div>
className="text-muted-foreground text-sm hover:underline" <div
title="Click to copy signing link for sending to recipient" className="text-muted-foreground text-sm"
> title="Click to copy signing link for sending to recipient"
{recipient.email} >
</span> <p>{recipient.email} </p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -1,4 +1,5 @@
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { import {
@@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)} fallbackText={recipientAbbreviation(recipient)}
/> />
<span className="text-muted-foreground text-sm">{recipient.email}</span> <div className="">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader, Monitor, Moon, Sun } from 'lucide-react'; import { Loader, Monitor, Moon, Sun } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@@ -13,6 +14,7 @@ import {
SETTINGS_PAGE_SHORTCUT, SETTINGS_PAGE_SHORTCUT,
TEMPLATES_PAGE_SHORTCUT, TEMPLATES_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts'; } from '@documenso/lib/constants/keyboard-shortcuts';
import type { Document, Recipient } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { import {
CommandDialog, CommandDialog,
@@ -65,6 +67,8 @@ export type CommandMenuProps = {
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const { setTheme } = useTheme(); const { setTheme } = useTheme();
const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
const [isOpen, setIsOpen] = useState(() => open ?? false); const [isOpen, setIsOpen] = useState(() => open ?? false);
@@ -81,6 +85,17 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
}, },
); );
const isOwner = useCallback(
(document: Document) => document.userId === session?.user.id,
[session?.user.id],
);
const getSigningLink = useCallback(
(recipients: Recipient[]) =>
`/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`,
[session?.user.email],
);
const searchResults = useMemo(() => { const searchResults = useMemo(() => {
if (!searchDocumentsData) { if (!searchDocumentsData) {
return []; return [];
@@ -88,10 +103,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
return searchDocumentsData.map((document) => ({ return searchDocumentsData.map((document) => ({
label: document.title, label: document.title,
path: `/documents/${document.id}`, path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient),
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '), value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
})); }));
}, [searchDocumentsData]); }, [searchDocumentsData, isOwner, getSigningLink]);
const currentPage = pages[pages.length - 1]; const currentPage = pages[pages.length - 1];
@@ -237,7 +252,11 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) =>
); );
return THEMES.map((theme) => ( return THEMES.map((theme) => (
<CommandItem key={theme.theme} onSelect={() => setTheme(theme.theme)}> <CommandItem
key={theme.theme}
onSelect={() => setTheme(theme.theme)}
className="mx-2 first:mt-2 last:mb-2"
>
<theme.icon className="mr-2" /> <theme.icon className="mr-2" />
{theme.label} {theme.label}
</CommandItem> </CommandItem>

View File

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

View File

@@ -68,7 +68,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount> <DropdownMenuContent className="z-[60] w-56" align="end" forceMount>
<DropdownMenuLabel>Account</DropdownMenuLabel> <DropdownMenuLabel>Account</DropdownMenuLabel>
{isUserAdmin && ( {isUserAdmin && (
@@ -122,7 +122,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
Themes Themes
</DropdownMenuSubTrigger> </DropdownMenuSubTrigger>
<DropdownMenuPortal> <DropdownMenuPortal>
<DropdownMenuSubContent> <DropdownMenuSubContent className="z-[60]">
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}> <DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
<DropdownMenuRadioItem value="light"> <DropdownMenuRadioItem value="light">
<Sun className="mr-2 h-4 w-4" /> Light <Sun className="mr-2 h-4 w-4" /> Light
@@ -141,7 +141,11 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href="https://github.com/documenso/documenso" className="cursor-pointer"> <Link
href="https://github.com/documenso/documenso"
className="cursor-pointer"
target="_blank"
>
<LuGithub className="mr-2 h-4 w-4" /> <LuGithub className="mr-2 h-4 w-4" />
Star on Github Star on Github
</Link> </Link>

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import { ONE_SECOND } from '@documenso/lib/constants/time'; import { ONE_DAY, ONE_SECOND } from '@documenso/lib/constants/time';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -65,7 +65,7 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
if (emailVerificationDialogLastShown) { if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown); const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) { if (Date.now() - lastShownTimestamp < ONE_DAY) {
return; return;
} }
} }

View File

@@ -1,4 +1,4 @@
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => { export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions

View File

@@ -19,27 +19,14 @@ export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps)
return ( return (
<> <>
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8"> <div className="flex-shrink-0">
<div className="flex-1"> {isTwoFactorEnabled ? (
<p>Authenticator app</p> <Button variant="destructive" onClick={() => setModalState('disable')}>
Disable 2FA
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm"> </Button>
Create one-time passwords that serve as a secondary authentication method for confirming ) : (
your identity when requested during the sign-in process. <Button onClick={() => setModalState('enable')}>Enable 2FA</Button>
</p> )}
</div>
<div>
{isTwoFactorEnabled ? (
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
Disable 2FA
</Button>
) : (
<Button onClick={() => setModalState('enable')} size="sm">
Enable 2FA
</Button>
)}
</div>
</div> </div>
<EnableAuthenticatorAppDialog <EnableAuthenticatorAppDialog

View File

@@ -11,6 +11,7 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
@@ -145,8 +146,8 @@ export const DisableAuthenticatorAppDialog = ({
/> />
</fieldset> </fieldset>
<div className="flex w-full items-center justify-between"> <DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}> <Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
@@ -157,7 +158,7 @@ export const DisableAuthenticatorAppDialog = ({
> >
Disable 2FA Disable 2FA
</Button> </Button>
</div> </DialogFooter>
</form> </form>
</Form> </Form>
</DialogContent> </DialogContent>

View File

@@ -15,6 +15,7 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
@@ -190,15 +191,15 @@ export const EnableAuthenticatorAppDialog = ({
)} )}
/> />
<div className="flex w-full items-center justify-between"> <DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}> <Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}> <Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
Continue Continue
</Button> </Button>
</div> </DialogFooter>
</form> </form>
</Form> </Form>
); );
@@ -251,15 +252,15 @@ export const EnableAuthenticatorAppDialog = ({
)} )}
/> />
<div className="flex w-full items-center justify-between"> <DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}> <Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}> <Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
Enable 2FA Enable 2FA
</Button> </Button>
</div> </DialogFooter>
</form> </form>
</Form> </Form>
)) ))

View File

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

View File

@@ -11,6 +11,7 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
@@ -119,15 +120,15 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
)} )}
/> />
<div className="flex w-full items-center justify-between"> <DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}> <Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button type="submit" loading={isViewRecoveryCodesSubmitting}> <Button type="submit" loading={isViewRecoveryCodesSubmitting}>
Continue Continue
</Button> </Button>
</div> </DialogFooter>
</form> </form>
</Form> </Form>
); );

View File

@@ -7,6 +7,7 @@ import { z } from 'zod';
import type { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -22,18 +23,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZPasswordFormSchema = z export const ZPasswordFormSchema = z
.object({ .object({
currentPassword: z currentPassword: ZCurrentPasswordSchema,
.string() password: ZPasswordSchema,
.min(6, { message: 'Password should contain at least 6 characters' }) repeatedPassword: ZPasswordSchema,
.max(72, { message: 'Password should not contain more than 72 characters' }),
password: z
.string()
.min(6, { message: 'Password should contain at least 6 characters' })
.max(72, { message: 'Password should not contain more than 72 characters' }),
repeatedPassword: z
.string()
.min(6, { message: 'Password should contain at least 6 characters' })
.max(72, { message: 'Password should not contain more than 72 characters' }),
}) })
.refine((data) => data.password === data.repeatedPassword, { .refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match', message: 'Passwords do not match',
@@ -145,7 +137,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
/> />
</fieldset> </fieldset>
<div className="mt-4"> <div className="ml-auto mt-4">
<Button type="submit" loading={isSubmitting}> <Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Updating password...' : 'Update password'} {isSubmitting ? 'Updating password...' : 'Update password'}
</Button> </Button>

View File

@@ -112,7 +112,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
</Label> </Label>
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled /> <Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
</div> </div>
<FormField <FormField
control={form.control} control={form.control}
name="signature" name="signature"
@@ -122,7 +121,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
<FormControl> <FormControl>
<SignaturePad <SignaturePad
className="h-44 w-full" className="h-44 w-full"
containerClassName="rounded-lg border bg-background" disabled={isSubmitting}
containerClassName={cn('rounded-lg border bg-background')}
defaultValue={user.signature ?? undefined} defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')} onChange={(v) => onChange(v ?? '')}
/> />

View File

@@ -8,6 +8,7 @@ import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -23,8 +24,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZResetPasswordFormSchema = z export const ZResetPasswordFormSchema = z
.object({ .object({
password: z.string().min(6).max(72), password: ZPasswordSchema,
repeatedPassword: z.string().min(6).max(72), repeatedPassword: ZPasswordSchema,
}) })
.refine((data) => data.password === data.repeatedPassword, { .refine((data) => data.password === data.repeatedPassword, {
path: ['repeatedPassword'], path: ['repeatedPassword'],

View File

@@ -9,9 +9,16 @@ import { FcGoogle } from 'react-icons/fc';
import { z } from 'zod'; import { z } from 'zod';
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes'; import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog'; import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { import {
Form, Form,
FormControl, FormControl,
@@ -39,7 +46,7 @@ const LOGIN_REDIRECT_PATH = '/documents';
export const ZSignInFormSchema = z.object({ export const ZSignInFormSchema = z.object({
email: z.string().email().min(1), email: z.string().email().min(1),
password: z.string().min(6).max(72), password: ZCurrentPasswordSchema,
totpCode: z.string().trim().optional(), totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(), backupCode: z.string().trim().optional(),
}); });
@@ -48,9 +55,10 @@ export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
export type SignInFormProps = { export type SignInFormProps = {
className?: string; className?: string;
isGoogleSSOEnabled?: boolean;
}; };
export const SignInForm = ({ className }: SignInFormProps) => { export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false); useState(false);
@@ -109,7 +117,6 @@ export const SignInForm = ({ className }: SignInFormProps) => {
const result = await signIn('credentials', { const result = await signIn('credentials', {
...credentials, ...credentials,
callbackUrl: LOGIN_REDIRECT_PATH, callbackUrl: LOGIN_REDIRECT_PATH,
redirect: false, redirect: false,
}); });
@@ -203,24 +210,29 @@ export const SignInForm = ({ className }: SignInFormProps) => {
{isSubmitting ? 'Signing in...' : 'Sign In'} {isSubmitting ? 'Signing in...' : 'Sign In'}
</Button> </Button>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase"> {isGoogleSSOEnabled && (
<div className="bg-border h-px flex-1" /> <>
<span className="text-muted-foreground bg-transparent">Or continue with</span> <div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" /> <div className="bg-border h-px flex-1" />
</div> <span className="text-muted-foreground bg-transparent">Or continue with</span>
<div className="bg-border h-px flex-1" />
</div>
<Button <Button
type="button" type="button"
size="lg" size="lg"
variant={'outline'} variant="outline"
className="bg-background text-muted-foreground border" className="bg-background text-muted-foreground border"
disabled={isSubmitting} disabled={isSubmitting}
onClick={onSignInWithGoogleClick} onClick={onSignInWithGoogleClick}
> >
<FcGoogle className="mr-2 h-5 w-5" /> <FcGoogle className="mr-2 h-5 w-5" />
Google Google
</Button> </Button>
</>
)}
</form> </form>
<Dialog <Dialog
open={isTwoFactorAuthenticationDialogOpen} open={isTwoFactorAuthenticationDialogOpen}
onOpenChange={onCloseTwoFactorAuthenticationDialog} onOpenChange={onCloseTwoFactorAuthenticationDialog}
@@ -263,21 +275,23 @@ export const SignInForm = ({ className }: SignInFormProps) => {
)} )}
/> />
)} )}
<DialogFooter className="mt-4">
<Button
type="button"
variant="secondary"
onClick={onToggleTwoFactorAuthenticationMethodClick}
>
{twoFactorAuthenticationMethod === 'totp'
? 'Use Backup Code'
: 'Use Authenticator'}
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</DialogFooter>
</fieldset> </fieldset>
<div className="mt-4 flex items-center justify-between">
<Button
type="button"
variant="ghost"
onClick={onToggleTwoFactorAuthenticationMethodClick}
>
{twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</div>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -3,11 +3,13 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { FcGoogle } from 'react-icons/fc';
import { z } from 'zod'; import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@@ -23,23 +25,33 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSignUpFormSchema = z.object({ const SIGN_UP_REDIRECT_PATH = '/documents';
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1), export const ZSignUpFormSchema = z
password: z .object({
.string() name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
.min(6, { message: 'Password should contain at least 6 characters' }) email: z.string().email().min(1),
.max(72, { message: 'Password should not contain more than 72 characters' }), password: ZPasswordSchema,
signature: z.string().min(1, { message: 'We need your signature to sign documents' }), signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
}); })
.refine(
(data) => {
const { name, email, password } = data;
return !password.includes(name) && !password.includes(email.split('@')[0]);
},
{
message: 'Password should not be common or based on personal information',
},
);
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>; export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
export type SignUpFormProps = { export type SignUpFormProps = {
className?: string; className?: string;
isGoogleSSOEnabled?: boolean;
}; };
export const SignUpForm = ({ className }: SignUpFormProps) => { export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const analytics = useAnalytics(); const analytics = useAnalytics();
@@ -64,7 +76,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
await signIn('credentials', { await signIn('credentials', {
email, email,
password, password,
callbackUrl: '/', callbackUrl: SIGN_UP_REDIRECT_PATH,
}); });
analytics.capture('App: User Sign Up', { analytics.capture('App: User Sign Up', {
@@ -89,6 +101,19 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
} }
}; };
const onSignUpWithGoogleClick = async () => {
try {
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
} catch (err) {
toast({
title: 'An unknown error occurred',
description:
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
variant: 'destructive',
});
}
};
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -147,6 +172,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
<FormControl> <FormControl>
<SignaturePad <SignaturePad
className="h-36 w-full" className="h-36 w-full"
disabled={isSubmitting}
containerClassName="mt-2 rounded-lg border bg-background" containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')} onChange={(v) => onChange(v ?? '')}
/> />
@@ -166,6 +192,28 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
> >
{isSubmitting ? 'Signing up...' : 'Sign Up'} {isSubmitting ? 'Signing up...' : 'Sign Up'}
</Button> </Button>
{isGoogleSSOEnabled && (
<>
<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</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={onSignUpWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Sign Up with Google
</Button>
</>
)}
</form> </form>
</Form> </Form>
); );

View File

@@ -1,17 +1,65 @@
// import { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth'; import NextAuth from 'next-auth';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
export default NextAuth({ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
...NEXT_AUTH_OPTIONS, const { ipAddress, userAgent } = extractNextApiRequestMetadata(req);
pages: {
signIn: '/signin',
signOut: '/signout',
error: '/signin',
},
});
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) { return await NextAuth(req, res, {
// res.json({ hello: 'world' }); ...NEXT_AUTH_OPTIONS,
// } pages: {
signIn: '/signin',
signOut: '/signout',
error: '/signin',
},
events: {
signIn: async ({ user }) => {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
});
},
signOut: async ({ token }) => {
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;
if (isNaN(userId)) {
return;
}
await prisma.userSecurityAuditLog.create({
data: {
userId,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_OUT,
},
});
},
linkAccount: async ({ user }) => {
const userId = typeof user.id === 'string' ? parseInt(user.id) : user.id;
if (isNaN(userId)) {
return;
}
await prisma.userSecurityAuditLog.create({
data: {
userId,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
},
});
},
},
});
}

View File

@@ -39,6 +39,14 @@ ENV HUSKY 0
ENV DOCKER_OUTPUT 1 ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED 1
# Encryption keys
ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
ENV NEXT_PRIVATE_ENCRYPTION_KEY="$NEXT_PRIVATE_ENCRYPTION_KEY"
ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
# Uncomment and use build args to enable remote caching # Uncomment and use build args to enable remote caching
# ARG TURBO_TEAM # ARG TURBO_TEAM
# ENV TURBO_TEAM=$TURBO_TEAM # ENV TURBO_TEAM=$TURBO_TEAM

View File

@@ -23,7 +23,8 @@ services:
- database - database
- inbucket - inbucket
environment: environment:
- DATABASE_URL=postgres://documenso:password@database:5432/documenso - NEXT_PRIVATE_DATABASE_URL=postgres://documenso:password@database:5432/documenso
- NEXT_PRIVATE_DIRECT_DATABASE_URL=postgres://documenso:password@database:5432/documenso
- NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000 - NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
- NEXTAUTH_SECRET=my-super-secure-secret - NEXTAUTH_SECRET=my-super-secure-secret
- NEXTAUTH_URL=http://localhost:3000 - NEXTAUTH_URL=http://localhost:3000

35
package-lock.json generated
View File

@@ -158,6 +158,7 @@
"sharp": "0.33.1", "sharp": "0.33.1",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"typescript": "5.2.2", "typescript": "5.2.2",
"ua-parser-js": "^1.0.37",
"uqr": "^0.1.2", "uqr": "^0.1.2",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
@@ -166,7 +167,8 @@
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@types/node": "20.1.0", "@types/node": "20.1.0",
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7" "@types/react-dom": "18.2.7",
"@types/ua-parser-js": "^0.7.39"
} }
}, },
"apps/web/node_modules/@types/node": { "apps/web/node_modules/@types/node": {
@@ -6756,6 +6758,12 @@
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==" "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A=="
}, },
"node_modules/@types/ua-parser-js": {
"version": "0.7.39",
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz",
"integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==",
"dev": true
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "2.0.10", "version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
@@ -18643,6 +18651,28 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/ua-parser-js": {
"version": "1.0.37",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz",
"integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"engines": {
"node": "*"
}
},
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
@@ -19869,7 +19899,8 @@
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0", "tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5", "tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5" "ts-pattern": "^5.0.5",
"zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@documenso/tailwind-config": "*", "@documenso/tailwind-config": "*",

View File

@@ -12,7 +12,7 @@ test.describe.configure({ mode: 'serial' });
const username = 'Test User'; const username = 'Test User';
const email = 'test-user@auth-flow.documenso.com'; const email = 'test-user@auth-flow.documenso.com';
const password = 'Password123'; const password = 'Password123#';
test('user can sign up with email and password', async ({ page }: { page: Page }) => { test('user can sign up with email and password', async ({ page }: { page: Page }) => {
await page.goto('/signup'); await page.goto('/signup');

View File

@@ -1,3 +1,6 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { RecipientRole } from '@documenso/prisma/client';
import { Button, Section, Text } from '../components'; import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image'; import { TemplateDocumentImage } from './template-document-image';
@@ -7,6 +10,7 @@ export interface TemplateDocumentInviteProps {
documentName: string; documentName: string;
signDocumentLink: string; signDocumentLink: string;
assetBaseUrl: string; assetBaseUrl: string;
role: RecipientRole;
} }
export const TemplateDocumentInvite = ({ export const TemplateDocumentInvite = ({
@@ -14,19 +18,22 @@ export const TemplateDocumentInvite = ({
documentName, documentName,
signDocumentLink, signDocumentLink,
assetBaseUrl, assetBaseUrl,
role,
}: TemplateDocumentInviteProps) => { }: TemplateDocumentInviteProps) => {
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
return ( return (
<> <>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} /> <TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section> <Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold"> <Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{inviterName} has invited you to sign {inviterName} has invited you to {actionVerb.toLowerCase()}
<br />"{documentName}" <br />"{documentName}"
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">
Continue by signing the document. Continue by {progressiveVerb.toLowerCase()} the document.
</Text> </Text>
<Section className="mb-6 mt-8 text-center"> <Section className="mb-6 mt-8 text-center">
@@ -34,7 +41,7 @@ export const TemplateDocumentInvite = ({
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline" className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={signDocumentLink} href={signDocumentLink}
> >
Sign Document {actionVerb} Document
</Button> </Button>
</Section> </Section>
</Section> </Section>

View File

@@ -10,7 +10,7 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
{isDocument && ( {isDocument && (
<Text className="my-4 text-base text-slate-400"> <Text className="my-4 text-base text-slate-400">
This document was sent using{' '} This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documenso.com"> <Link className="text-[#7AC455]" href="https://documen.so/mail-footer">
Documenso. Documenso.
</Link> </Link>
</Text> </Text>

View File

@@ -1,3 +1,5 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { RecipientRole } from '@documenso/prisma/client';
import config from '@documenso/tailwind-config'; import config from '@documenso/tailwind-config';
import { import {
@@ -19,6 +21,7 @@ import { TemplateFooter } from '../template-components/template-footer';
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & { export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
customBody?: string; customBody?: string;
role: RecipientRole;
}; };
export const DocumentInviteEmailTemplate = ({ export const DocumentInviteEmailTemplate = ({
@@ -28,8 +31,11 @@ export const DocumentInviteEmailTemplate = ({
signDocumentLink = 'https://documenso.com', signDocumentLink = 'https://documenso.com',
assetBaseUrl = 'http://localhost:3002', assetBaseUrl = 'http://localhost:3002',
customBody, customBody,
role,
}: DocumentInviteEmailTemplateProps) => { }: DocumentInviteEmailTemplateProps) => {
const previewText = `${inviterName} has invited you to sign ${documentName}`; const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
const getAssetUrl = (path: string) => { const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString(); return new URL(path, assetBaseUrl).toString();
@@ -64,6 +70,7 @@ export const DocumentInviteEmailTemplate = ({
documentName={documentName} documentName={documentName}
signDocumentLink={signDocumentLink} signDocumentLink={signDocumentLink}
assetBaseUrl={assetBaseUrl} assetBaseUrl={assetBaseUrl}
role={role}
/> />
</Section> </Section>
</Container> </Container>
@@ -81,7 +88,7 @@ export const DocumentInviteEmailTemplate = ({
{customBody ? ( {customBody ? (
<pre className="font-sans text-base text-slate-400">{customBody}</pre> <pre className="font-sans text-base text-slate-400">{customBody}</pre>
) : ( ) : (
`${inviterName} has invited you to sign the document "${documentName}".` `${inviterName} has invited you to ${action} the document "${documentName}".`
)} )}
</Text> </Text>
</Section> </Section>

View File

@@ -0,0 +1,29 @@
import type { DocumentData } from '@documenso/prisma/client';
import { getFile } from '../universal/upload/get-file';
type DownloadPDFProps = {
documentData: DocumentData;
fileName?: string;
};
export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps) => {
const bytes = await getFile(documentData);
const blob = new Blob([bytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
const [baseTitle] = fileName?.includes('.pdf')
? fileName.split('.pdf')
: [fileName ?? 'document'];
link.href = window.URL.createObjectURL(blob);
link.download = `${baseTitle}_signed.pdf`;
link.click();
window.URL.revokeObjectURL(link.href);
};

View File

@@ -1,10 +1,10 @@
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientType = (recipient: Recipient) => { export const getRecipientType = (recipient: Recipient) => {
if ( if (
recipient.sendStatus === SendStatus.SENT && recipient.role === RecipientRole.CC ||
recipient.signingStatus === SigningStatus.SIGNED (recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED)
) { ) {
return 'completed'; return 'completed';
} }

View File

@@ -6,3 +6,6 @@ export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
export const APP_BASE_URL = IS_APP_WEB export const APP_BASE_URL = IS_APP_WEB
? process.env.NEXT_PUBLIC_WEBAPP_URL ? process.env.NEXT_PUBLIC_WEBAPP_URL
: process.env.NEXT_PUBLIC_MARKETING_URL; : process.env.NEXT_PUBLIC_MARKETING_URL;
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;

View File

@@ -1 +1,25 @@
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
export const SALT_ROUNDS = 12; export const SALT_ROUNDS = 12;
export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
[IdentityProvider.DOCUMENSO]: 'Documenso',
[IdentityProvider.GOOGLE]: 'Google',
};
export const IS_GOOGLE_SSO_ENABLED = Boolean(
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
);
export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = {
[UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO',
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
};

View File

@@ -1 +1,25 @@
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY; export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
if (typeof window === 'undefined') {
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
}
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error(
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
);
}
}
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {
console.warn('*********************************************************************');
console.warn('*');
console.warn('*');
console.warn('Please change the encryption key from the default value of "CAFEBABE"');
console.warn('*');
console.warn('*');
console.warn('*********************************************************************');
}

View File

@@ -1,7 +1,7 @@
import { APP_BASE_URL } from './app'; import { APP_BASE_URL } from './app';
export const DEFAULT_STANDARD_FONT_SIZE = 15; export const DEFAULT_STANDARD_FONT_SIZE = 15;
export const DEFAULT_HANDWRITING_FONT_SIZE = 30; export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const MIN_STANDARD_FONT_SIZE = 8; export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20; export const MIN_HANDWRITING_FONT_SIZE = 20;

View File

@@ -0,0 +1,26 @@
import { RecipientRole } from '@documenso/prisma/client';
export const RECIPIENT_ROLES_DESCRIPTION: {
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
} = {
[RecipientRole.APPROVER]: {
actionVerb: 'Approve',
progressiveVerb: 'Approving',
roleName: 'Approver',
},
[RecipientRole.CC]: {
actionVerb: 'CC',
progressiveVerb: 'CC',
roleName: 'CC',
},
[RecipientRole.SIGNER]: {
actionVerb: 'Sign',
progressiveVerb: 'Signing',
roleName: 'Signer',
},
[RecipientRole.VIEWER]: {
actionVerb: 'View',
progressiveVerb: 'Viewing',
roleName: 'Viewer',
},
};

View File

@@ -9,10 +9,12 @@ import type { GoogleProfile } from 'next-auth/providers/google';
import GoogleProvider from 'next-auth/providers/google'; import GoogleProvider from 'next-auth/providers/google';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
import { getUserByEmail } from '../server-only/user/get-user-by-email'; import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { ErrorCode } from './error-codes'; import { ErrorCode } from './error-codes';
export const NEXT_AUTH_OPTIONS: AuthOptions = { export const NEXT_AUTH_OPTIONS: AuthOptions = {
@@ -34,7 +36,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}, },
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' }, backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
}, },
authorize: async (credentials, _req) => { authorize: async (credentials, req) => {
if (!credentials) { if (!credentials) {
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND); throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
} }
@@ -50,8 +52,18 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
} }
const isPasswordsSame = await compare(password, user.password); const isPasswordsSame = await compare(password, user.password);
const requestMetadata = extractNextAuthRequestMetadata(req);
if (!isPasswordsSame) { if (!isPasswordsSame) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_FAIL,
},
});
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD); throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
} }
@@ -61,6 +73,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user }); const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
if (!isValid) { if (!isValid) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
},
});
throw new Error( throw new Error(
totpCode totpCode
? ErrorCode.INCORRECT_TWO_FACTOR_CODE ? ErrorCode.INCORRECT_TWO_FACTOR_CODE
@@ -93,7 +114,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}), }),
], ],
callbacks: { callbacks: {
async jwt({ token, user }) { async jwt({ token, user, trigger, account }) {
const merged = { const merged = {
...token, ...token,
...user, ...user,
@@ -138,6 +159,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
merged.emailVerified = user.emailVerified?.toISOString() ?? null; merged.emailVerified = user.emailVerified?.toISOString() ?? null;
} }
if ((trigger === 'signIn' || trigger === 'signUp') && account?.provider === 'google') {
merged.emailVerified = user?.emailVerified
? new Date(user.emailVerified).toISOString()
: new Date().toISOString();
await prisma.user.update({
where: {
id: Number(merged.id),
},
data: {
emailVerified: merged.emailVerified,
identityProvider: IdentityProvider.GOOGLE,
},
});
}
return { return {
id: merged.id, id: merged.id,
name: merged.name, name: merged.name,
@@ -175,4 +212,5 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
return true; return true;
}, },
}, },
// Note: `events` are handled in `apps/web/src/pages/api/auth/[...nextauth].ts` to allow access to the request.
}; };

View File

@@ -1,21 +1,25 @@
import { compare } from 'bcrypt'; import { compare } from 'bcrypt';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes'; import { ErrorCode } from '../../next-auth/error-codes';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { validateTwoFactorAuthentication } from './validate-2fa'; import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = { type DisableTwoFactorAuthenticationOptions = {
user: User; user: User;
backupCode: string; backupCode: string;
password: string; password: string;
requestMetadata?: RequestMetadata;
}; };
export const disableTwoFactorAuthentication = async ({ export const disableTwoFactorAuthentication = async ({
backupCode, backupCode,
user, user,
password, password,
requestMetadata,
}: DisableTwoFactorAuthenticationOptions) => { }: DisableTwoFactorAuthenticationOptions) => {
if (!user.password) { if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD); throw new Error(ErrorCode.USER_MISSING_PASSWORD);
@@ -33,15 +37,26 @@ export const disableTwoFactorAuthentication = async ({
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE); throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
} }
await prisma.user.update({ await prisma.$transaction(async (tx) => {
where: { await tx.user.update({
id: user.id, where: {
}, id: user.id,
data: { },
twoFactorEnabled: false, data: {
twoFactorBackupCodes: null, twoFactorEnabled: false,
twoFactorSecret: null, twoFactorBackupCodes: null,
}, twoFactorSecret: null,
},
});
await tx.userSecurityAuditLog.create({
data: {
userId: user.id,
type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
userAgent: requestMetadata?.userAgent,
ipAddress: requestMetadata?.ipAddress,
},
});
}); });
return true; return true;

View File

@@ -1,18 +1,21 @@
import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client'; import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getBackupCodes } from './get-backup-code'; import { getBackupCodes } from './get-backup-code';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
type EnableTwoFactorAuthenticationOptions = { type EnableTwoFactorAuthenticationOptions = {
user: User; user: User;
code: string; code: string;
requestMetadata?: RequestMetadata;
}; };
export const enableTwoFactorAuthentication = async ({ export const enableTwoFactorAuthentication = async ({
user, user,
code, code,
requestMetadata,
}: EnableTwoFactorAuthenticationOptions) => { }: EnableTwoFactorAuthenticationOptions) => {
if (user.identityProvider !== 'DOCUMENSO') { if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER); throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
@@ -32,13 +35,24 @@ export const enableTwoFactorAuthentication = async ({
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE); throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
} }
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.$transaction(async (tx) => {
where: { await tx.userSecurityAuditLog.create({
id: user.id, data: {
}, userId: user.id,
data: { type: UserSecurityAuditLogType.AUTH_2FA_ENABLE,
twoFactorEnabled: true, userAgent: requestMetadata?.userAgent,
}, ipAddress: requestMetadata?.ipAddress,
},
});
return await tx.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
});
}); });
const recoveryCodes = getBackupCodes({ user: updatedUser }); const recoveryCodes = getBackupCodes({ user: updatedUser });

View File

@@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client'; import { type User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricEncrypt } from '../../universal/crypto'; import { symmetricEncrypt } from '../../universal/crypto';

View File

@@ -0,0 +1,33 @@
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
import { ZEncryptedDataSchema } from '@documenso/lib/server-only/crypto/encrypt';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
/**
* Decrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
*
* @param encryptedData The data encrypted with the `encryptSecondaryData` function.
* @returns The decrypted value, or `null` if the data is invalid or expired.
*/
export const decryptSecondaryData = (encryptedData: string): string | null => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
const decryptedBufferValue = symmetricDecrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: encryptedData,
});
const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8');
const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue));
if (!result.success) {
return null;
}
if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) {
return null;
}
return result.data.data;
};

View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import type { TEncryptSecondaryDataMutationSchema } from '@documenso/trpc/server/crypto/schema';
export const ZEncryptedDataSchema = z.object({
data: z.string(),
expiresAt: z.number().optional(),
});
export type EncryptDataOptions = {
data: string;
/**
* When the data should no longer be allowed to be decrypted.
*
* Leave this empty to never expire the data.
*/
expiresAt?: number;
};
/**
* Encrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
*
* @returns The encrypted data.
*/
export const encryptSecondaryData = ({ data, expiresAt }: TEncryptSecondaryDataMutationSchema) => {
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
throw new Error('Missing encryption key');
}
const dataToEncrypt: z.infer<typeof ZEncryptedDataSchema> = {
data,
expiresAt,
};
return symmetricEncrypt({
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
data: JSON.stringify(dataToEncrypt),
});
};

View File

@@ -4,10 +4,11 @@ import { prisma } from '@documenso/prisma';
export type CreateDocumentMetaOptions = { export type CreateDocumentMetaOptions = {
documentId: number; documentId: number;
subject: string; subject?: string;
message: string; message?: string;
timezone: string; timezone?: string;
dateFormat: string; password?: string;
dateFormat?: string;
userId: number; userId: number;
}; };
@@ -18,6 +19,7 @@ export const upsertDocumentMeta = async ({
dateFormat, dateFormat,
documentId, documentId,
userId, userId,
password,
}: CreateDocumentMetaOptions) => { }: CreateDocumentMetaOptions) => {
await prisma.document.findFirstOrThrow({ await prisma.document.findFirstOrThrow({
where: { where: {
@@ -35,12 +37,14 @@ export const upsertDocumentMeta = async ({
message, message,
dateFormat, dateFormat,
timezone, timezone,
password,
documentId, documentId,
}, },
update: { update: {
subject, subject,
message, message,
dateFormat, dateFormat,
password,
timezone, timezone,
}, },
}); });

View File

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

View File

@@ -3,10 +3,13 @@ import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Document, Prisma } from '@documenso/prisma/client'; import type { Document, Prisma } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client'; import { RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { FindResultSet } from '../../types/find-result-set'; import type { FindResultSet } from '../../types/find-result-set';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
export type FindDocumentsOptions = { export type FindDocumentsOptions = {
userId: number; userId: number;
@@ -18,7 +21,7 @@ export type FindDocumentsOptions = {
column: keyof Omit<Document, 'document'>; column: keyof Omit<Document, 'document'>;
direction: 'asc' | 'desc'; direction: 'asc' | 'desc';
}; };
period?: '' | '7d' | '14d' | '30d'; period?: PeriodSelectorValue;
}; };
export const findDocuments = async ({ export const findDocuments = async ({
@@ -84,6 +87,9 @@ export const findDocuments = async ({
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
}, },
}, },
deletedAt: null, deletedAt: null,
@@ -106,6 +112,9 @@ export const findDocuments = async ({
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
role: {
not: RecipientRole.CC,
},
}, },
}, },
deletedAt: null, deletedAt: null,
@@ -173,8 +182,15 @@ export const findDocuments = async ({
}), }),
]); ]);
const maskedData = data.map((document) =>
maskRecipientTokensForDocument({
document,
user,
}),
);
return { return {
data, data: maskedData,
count, count,
currentPage: Math.max(page, 1), currentPage: Math.max(page, 1),
perPage, perPage,

View File

@@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
export interface GetDocumentAndSenderByTokenOptions { export interface GetDocumentAndSenderByTokenOptions {
token: string; token: string;
@@ -58,7 +58,11 @@ export const getDocumentAndRecipientByToken = async ({
}, },
}, },
include: { include: {
Recipient: true, Recipient: {
where: {
token,
},
},
documentData: true, documentData: true,
}, },
}); });

View File

@@ -1,14 +1,31 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client'; import type { Prisma, User } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client'; import { SigningStatus } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { PeriodSelectorValue } from './find-documents';
export type GetStatsInput = { export type GetStatsInput = {
user: User; user: User;
period?: PeriodSelectorValue;
}; };
export const getStats = async ({ user }: GetStatsInput) => { export const getStats = async ({ user, period }: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
createdAt = {
gte: startOfPeriod.toJSDate(),
};
}
const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([ const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
@@ -17,6 +34,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
}, },
where: { where: {
userId: user.id, userId: user.id,
createdAt,
deletedAt: null, deletedAt: null,
}, },
}), }),
@@ -33,6 +51,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
}, },
}, },
createdAt,
deletedAt: null, deletedAt: null,
}, },
}), }),
@@ -42,6 +61,12 @@ export const getStats = async ({ user }: GetStatsInput) => {
_all: true, _all: true,
}, },
where: { where: {
createdAt,
User: {
email: {
not: user.email,
},
},
OR: [ OR: [
{ {
status: ExtendedDocumentStatus.PENDING, status: ExtendedDocumentStatus.PENDING,

View File

@@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
export type ResendDocumentOptions = { export type ResendDocumentOptions = {
documentId: number; documentId: number;
@@ -59,6 +61,10 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
await Promise.all( await Promise.all(
document.Recipient.map(async (recipient) => { document.Recipient.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) {
return;
}
const { email, name } = recipient; const { email, name } = recipient;
const customEmailTemplate = { const customEmailTemplate = {
@@ -77,8 +83,11 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
role: recipient.role,
}); });
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await mailer.sendMail({ await mailer.sendMail({
to: { to: {
address: email, address: email,
@@ -90,7 +99,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: 'Please sign this document', : `Please ${actionVerb.toLowerCase()} this document`,
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
}); });

View File

@@ -6,7 +6,7 @@ import { PDFDocument } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing'; import { signPdf } from '@documenso/signing';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
@@ -44,6 +44,9 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
const recipients = await prisma.recipient.findMany({ const recipients = await prisma.recipient.findMany({
where: { where: {
documentId: document.id, documentId: document.id,
role: {
not: RecipientRole.CC,
},
}, },
}); });

View File

@@ -1,6 +1,8 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
export type SearchDocumentsWithKeywordOptions = { export type SearchDocumentsWithKeywordOptions = {
query: string; query: string;
userId: number; userId: number;
@@ -77,5 +79,12 @@ export const searchDocumentsWithKeyword = async ({
take: limit, take: limit,
}); });
return documents; const maskedDocuments = documents.map((document) =>
maskRecipientTokensForDocument({
document,
user,
}),
);
return maskedDocuments;
}; };

View File

@@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
export type SendDocumentOptions = { export type SendDocumentOptions = {
documentId: number; documentId: number;
@@ -47,6 +49,10 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
await Promise.all( await Promise.all(
document.Recipient.map(async (recipient) => { document.Recipient.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
const { email, name } = recipient; const { email, name } = recipient;
const customEmailTemplate = { const customEmailTemplate = {
@@ -55,10 +61,6 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
'document.name': document.title, 'document.name': document.title,
}; };
if (recipient.sendStatus === SendStatus.SENT) {
return;
}
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`; const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
@@ -69,8 +71,11 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
role: recipient.role,
}); });
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await mailer.sendMail({ await mailer.sendMail({
to: { to: {
address: email, address: email,
@@ -82,7 +87,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: 'Please sign this document', : `Please ${actionVerb.toLowerCase()} this document`,
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
}); });

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