Compare commits

..

92 Commits

Author SHA1 Message Date
7746619dac Merge branch 'main' of https://github.com/documenso/documenso into feat/publicProfile 2024-05-09 12:47:38 +05:30
8f9c07aa8e chore: updated triage label (#1152)
Description:

This PR updates the triage label for new issues
2024-05-08 17:56:23 +05:30
cc4efddabf chore: updated triage label 2024-05-08 17:03:57 +05:30
2ba0f48c61 fix: unauthorized access error api tokens page team (#1134) 2024-05-08 12:03:21 +07:00
5d5d0210fa chore: update github actions (#1085)
**Description:**

This PR updates and adds a new action to assign `status: assigned` label

---------

Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-08 11:52:26 +07:00
e50ccca766 fix: allow template recipients to be filled (#1148)
## Description

Update the template flow to allow for entering recipient placeholder
emails and names

## Changes Made

- General refactoring
- Added advanced recipient settings for future usage
2024-05-07 17:22:24 +07:00
d7a3c40050 feat: add general template enhancements (#1147)
## Description

Refactor the "use template" flow

## Changes Made

- Add placeholders for recipients
- Add audit log when document is created
- Trigger DOCUMENT_CREATED webhook when document is created
- Remove role field when using template
- Remove flaky logic when associating template recipients with form
recipients
- Refactor to use `Form` 

### Using template when document has no recipients

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/a8494ac9-0397-4e3b-a0cf-818c8454a55c">

### Using template with recipients 

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/54d949fc-ed6a-4318-bfd6-6a3179896ba9">

### Using template with the send option selected

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/541b2664-0540-43e9-83dd-e040a45a44ea">
2024-05-07 15:04:12 +07:00
dc11676d28 fix: profile claim name length (#1144)
fixes the caim name length on the profile claim popup
2024-05-07 14:42:16 +07:00
e8d4fe46e5 fix: custom email message for self-signers (#1120) 2024-05-06 09:22:50 +03:00
64e3e2c64b fix: disable encrypted pdfs (#1130)
## Description

Currently if you complete a pending encrypted document, it will prevent
the document from being sealed due to the systems inability to decrypt
it.

This PR disables uploading any documents that cannot be loaded as a
temporary measure.

**Note**
This is a client side only check

## Changes Made

- Disable uploading documents that cannot be parsed
- Refactor putFile to putDocumentFile
- Add a flag as a backup incase something goes wrong
2024-05-03 22:25:24 +07:00
23672d47f1 chore: add copy button
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-01 17:57:31 +05:30
c60bcc6309 chore: add profile url
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-01 16:30:34 +05:30
69c175d38e feat: initiated public profile
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-01 16:16:55 +05:30
15dee5ef35 fix: enforce users to have stripe account (#1131)
## Description

Currently users who sign in via Google SSO do not get assigned a Stripe
customer account.

This enforces the Stripe customer requirement on sign in.

There might be a better place to put this so it's open to any
suggestions.
2024-05-01 16:48:05 +10:00
28d6f6e2e8 fix: improve sealing process (#1133)
## Description

Improves the sealing process by being strict on how long certificate
generation can take, opting to fail generation and continue sealing.

Also changes the ordering of sealing so an error in the process won't
also cause a document to be "COMPLETED" since it hasn't been
cryptographically sealed yet.

The downside to this change is that documents that fail during sealing
will require manual intervention as a signer or owner won't be able to
*complete* the document.

## Testing Performed

- Modified code to force specific failure modes to occur and verified
that documents were either gracefully sealed without a certificate or
not sealed and not completed.
2024-05-01 16:47:11 +10:00
78dc57a6eb fix: improvements from review 2024-05-01 16:16:04 +10:00
d3528f74f0 fix: improve sealing process
Improves the sealing process by being strict on how
long certificate generation can take, opting to fail
generation and continue sealing.

Also changes the ordering of sealing so an error in the
process won't also cause a document to be "COMPLETED"
since it hasn't been cryptographically sealed yet.

The downside to this change is that documents that fail
during sealing will require manual intervention as a signer
or owner won't be able to *complete* the document.
2024-05-01 14:18:01 +10:00
dbd452be97 fix: delete pending documents (#1118)
## Description

Currently deleting a pending document where you are a recipient off will
delete the document, but will also throw an error.

This is due to the recipient being updated after the document deleted,
which is only supposed to happen for completed documents.
2024-04-30 20:53:18 +07:00
5109bb17d6 chore: fix button styling (#1132)
**Description:**

This PR fixes the button styling issue

**Before:**


![image](https://github.com/documenso/documenso/assets/23498248/0af045aa-3714-48d8-9c22-6cd171b07079)

**After:**

<img width="1280" alt="Screenshot 2024-04-30 at 6 48 47 PM"
src="https://github.com/documenso/documenso/assets/23498248/e7dd99de-60fc-4cc2-aefc-21b130aa0116">
2024-04-30 18:57:29 +05:30
6974a76ed4 chore: fix button styling 2024-04-30 18:47:49 +05:30
5efb0894e6 chore: updated dark mode text (#1129)
Description:

This PR updates the dark mode text for this article,
https://app.documenso.com/articles/signature-disclosure
2024-04-30 17:14:28 +05:30
cfec366c1a fix: refactor 2024-04-30 15:54:24 +07:00
8622e68853 fix: add logging 2024-04-30 15:50:22 +07:00
0e16a86e74 chore: updated dark mode text 2024-04-30 11:55:01 +05:30
97d334a1da fix: force users to have a Stripe customer on sign in 2024-04-29 20:15:40 +07:00
345e42537a fix: include all document meta when using the public api 2024-04-29 12:42:22 +10:00
8a24ca2065 fix: complete document when all recipients are CC (#1113)
## Description

Automatically marks the document as completed if all the recipients are
CC.

## Changes Made

Added an if statement in the last form step (`onAddSubjectFormSubmit`)
that checks if all the recipients are CC. If so, the document status is
updated to `COMPLETED`.

## Testing Performed

Tested the changes and they work as expected.

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced document sending logic to update document status based on
recipient roles.

- **Bug Fixes**
- Removed redundant form submission handling in the document editing
feature.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-27 21:39:25 +10:00
06dd8219a5 fix: increase trpc max duration (#1121)
## Description

Increase the max duration of the TRPC API endpoint to 120 seconds.
2024-04-27 21:38:21 +10:00
74b9bc786b fix: extend 2024-04-27 18:29:52 +07:00
364c499927 fix: increase trpc max duration 2024-04-27 15:21:46 +07:00
b0ce06f6fe Merge branch 'main' into fix/doc-status-cc-role 2024-04-26 17:17:07 +07:00
20edee7f1a fix: ssr feature flags (#1119)
## Description

Feature flags are broken on SSR due to this error

```
TypeError: fetch failed
    at Object.fetch (node:internal/deps/undici/undici:11731:11)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
  cause: RequestContentLengthMismatchError: Request body length does not match content-length header
      at write (node:internal/deps/undici/undici:8590:41)
      at _resume (node:internal/deps/undici/undici:8563:33)
      at resume (node:internal/deps/undici/undici:8459:7)
      at [dispatch] (node:internal/deps/undici/undici:7704:11)
      at Client.Intercept (node:internal/deps/undici/undici:7377:20)
      at Client.dispatch (node:internal/deps/undici/undici:6023:44)
      at [dispatch] (node:internal/deps/undici/undici:6254:32)
      at Pool.dispatch (node:internal/deps/undici/undici:6023:44)
      at [dispatch] (node:internal/deps/undici/undici:9343:27)
      at Agent.Intercept (node:internal/deps/undici/undici:7377:20) {
    code: 'UND_ERR_REQ_CONTENT_LENGTH_MISMATCH'
  }
}
```

I've removed content-length header since it isn't mandatory to my
knowledge for get requests.

## Changes

- Add fallback local flags when individual flag request fails
- Add error logging
- Remove `content-length` from headers being passed to Posthog
2024-04-26 16:01:09 +07:00
9bc5818d19 fix: use cdp and upgrade playwright again (#1117)
## Description

Upgrade playwright once again and use CDP for remote connections to
avoid version lock-in with `playwright.connect`

Resolves the issue where all CI is failing currently due to downgrading
playwright.
2024-04-26 15:56:55 +10:00
481d739c37 chore: update package-lock 2024-04-26 13:25:16 +10:00
88dedc9829 fix: use cdp and upgrade playwright again 2024-04-26 13:18:31 +10:00
4080806606 fix: minor updates 2024-04-26 02:17:56 +00:00
e949fb14ae fix: hide team webhooks from users (#1116)
## Description

Currently if you create a team webhook, you can see it in your personal
webhooks.

However, interacting with them will throw errors because there is server
side logic preventing any interaction with them since they are outside
of the correct context.

So the solution is to either:
- Allow the user to edit the team webhooks that they created on their
personal webhooks page
- Hide team webhooks for personal webhooks pages

This PR goes with the second option, but is open to suggestions.
2024-04-26 11:14:52 +10:00
e1573465f6 fix: hide team webhooks from users 2024-04-25 23:32:59 +07:00
0062359977 feat: add visible completed fields (#1109)
## Description

Added the ability for recipients to see fields from other recipients who
have completed the document when they are signing the document

Added the ability for the document owner to see fields from recipients
who have completed the field on the document page view (only visible
when the document is pending)


## 🚨🚨 Migrations🚨🚨

- Drop all `Fields` that do not have a `Recipient` set (not sure how it
was possible in the first place)
- Remove optional `Recipient` field on `Field` which doesn't make sense 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
- Enhanced document viewing by adding read-only fields based on document
status.
- Improved signing page by fetching and displaying completed fields for
tokens.
- Updated avatar component to show recipient status with tooltips for
better user interaction.

- **Bug Fixes**
- Made `recipientId` a required field in the database to ensure data
consistency.

- **Refactor**
- Optimized popover functionality in UI components for better
performance and user experience.

- **Documentation**
- Added detailed component and function descriptions for new features in
the system.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-25 20:53:13 +10:00
03727bfad2 fix: disable cert download (#1114)
## Description

Disable the document certificate download button when the document is
not complete

## Changes Made

- Disable UI button
- Disable TRPC API endpoint

## Testing Performed

Tested locally for pending, draft and completed documents
2024-04-25 20:43:52 +10:00
c4a680caf7 fix: hide account action reauth (#1115)
## Description

Hide the account reauth option

<img width="527" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/ee2169c7-856b-41ae-b756-43f15c2e8f69">

<img width="527" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/42ecc50a-2a7d-461b-9994-1af8f4a147ed">
2024-04-25 20:42:39 +10:00
1e33bc2aa3 Merge branch 'main' into fix/doc-status-cc-role 2024-04-24 20:30:10 +07:00
4de122f814 fix: hide account action reauth 2024-04-24 20:07:38 +07:00
e4cf9c8251 fix: add server logic 2024-04-24 19:51:18 +07:00
41ed6c9ad7 fix: disable cert download when document not complete 2024-04-24 19:49:10 +07:00
713cd09a06 fix: downgrade playwright 2024-04-24 19:07:18 +10:00
87423e240a chore: update foreign key constraints 2024-04-24 17:32:11 +10:00
d7959950e2 fix: edit-document line 2024-04-24 09:41:34 +03:00
bb43547a45 fix: complete document when all recipients are CC 2024-04-24 09:39:47 +03:00
3fb69422e8 Merge branch 'main' into fix/doc-status-cc-role 2024-04-23 14:26:37 +03:00
4d5365bddc fix: complete document when all recipients are CC 2024-04-23 14:24:58 +03:00
9298213177 chore: added filename extension check (#1106)
**Description:**

This PR adds a check for filename title and if the title ends with
`.pdf` then the extension isnt added or else its added

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit


- **Bug Fixes**
- Enhanced email attachment handling to ensure PDF files are correctly
identified with a ".pdf" extension.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-23 15:04:38 +05:30
0eee570781 fix: complete document when all recipients are CC 2024-04-23 12:33:40 +03:00
afaeba9739 fix: resize fields 2024-04-22 13:31:49 +07:00
4b90adde6b feat: download completed docs via api (#1078)
## Description

Allow users to download a completed document via API.

## Testing Performed

Tested the code locally by trying to download both draft and completed
docs. Works as expected.

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Implemented functionality to download signed documents directly from
the app.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-19 18:04:11 +07:00
f6e6dac46c fix: update migration to drop invalid fields 2024-04-19 17:58:32 +07:00
a97ffa97a4 Merge branch 'main' into feat/visible-fields 2024-04-19 17:54:32 +07:00
fceb0eaac9 feat: update emails for self-signer (#1108)
## Description

Updated the email content based on whether the document owner is a
recipient or not.

If the document owner is a recipient (self-signer):
* the email subject will be `Please view/sign/approve your document`
* the email header will be `Please view/sign/approve your document
"<your-doc-title>"`
* the email content will be `You have initiated the document
"<your-doc-title>" that requires you to view/sign/approve it.`

Otherwise:
* the email subject will be `Please view/sign/approve this document`
* the email header will be `<doc-owner> has invited you to
view/sign/approve "<doc-title>"`
* the email content will be `<doc-owner> has invited you to
view/sign/approve the document "<doc-title>".`


## Related Issue

Related to #1091 

## Testing Performed

Tested the feature with a different number of recipients (including and
excluding the document owner - self-signer). Tested both the sending and
resending functionality.

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.

## UI Screenshots

![CleanShot 2024-04-18 at 12 26
11@2x](https://github.com/documenso/documenso/assets/25515812/ca80f625-befb-4cbc-a541-f2186379d2e8)
![CleanShot 2024-04-18 at 12 27
40@2x](https://github.com/documenso/documenso/assets/25515812/8bcbb6fc-ba98-4fa1-8538-2d062febd27b)
![CleanShot 2024-04-18 at 12 27
53@2x](https://github.com/documenso/documenso/assets/25515812/25d77d98-b5ec-4270-8ffa-43774fe70526)
![CleanShot 2024-04-18 at 12 30
00@2x](https://github.com/documenso/documenso/assets/25515812/a90bb8e3-3ea8-42ff-9971-559b3e81ae6f)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
- Enhanced the document invitation components to support scenarios where
the recipient is also the sender, providing customized email content and
subject lines.
- Introduced new properties in email templates to improve clarity and
relevance based on the user's role in the document signing process.

- **Refactor**
- Updated components to use a more flexible `headerContent` property for
displaying invitation headers, replacing previous individual inviter
details.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-19 17:45:33 +07:00
bd40e63392 fix: update document deletion logic (#1100) 2024-04-19 17:37:38 +07:00
6e09a4700b fix: prevent signing draft documents (#1111)
## Description

Currently users can sign and complete draft documents, which will result
in a completed document in an invalid state.

## Changes Made

- Prevent recipients from inserting or uninserting fields for draft
documents
- Prevent recipients from completing draft documents 
- Remove ability to copy signing tokens unless document is pending

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Enhanced document status visibility and control across various
components in the application. Users can now see and interact with
document statuses more dynamically in views like `DocumentPageView`,
`DocumentEditPageView`, and `DocumentsDataTable`.
- Improved document signing process with updated status checks, ensuring
actions like signing, completing, and removing fields are only available
under appropriate document statuses.

- **Bug Fixes**
- Adjusted document status validation logic in server-side operations to
prevent actions on incorrectly stated documents, enhancing the overall
security and functionality of document processing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-19 16:17:32 +07:00
6526377f1b feat: add visible completed fields 2024-04-18 21:56:31 +07:00
f8ddb0f922 chore: update filename for bulk recipients 2024-04-18 18:12:08 +05:30
96e4797cdd Merge branch 'main' into chore/pdf-extension 2024-04-18 18:02:58 +05:30
3d3c53db02 feat: add extra info for recipient roles (#1105)
## Description

Add additional information for each role to help document owners
understand what each role involves.

## Changes Made

![CleanShot 2024-04-16 at 10 24
19](https://github.com/documenso/documenso/assets/25515812/bac6cd7d-fbe2-4987-ac17-de08db882eda)
![CleanShot 2024-04-16 at 10 24
27](https://github.com/documenso/documenso/assets/25515812/1bd23021-e971-451a-8e36-df5db57687f7)
![CleanShot 2024-04-16 at 10 24
35](https://github.com/documenso/documenso/assets/25515812/e658e86e-7fa1-4a40-9ed9-317964388e61)

## Testing Performed

Tested the changes locally.

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Updated recipient role terminology and added tooltips in the
`AddSignersFormPartial` component:
		- "Signer" changed to "Needs to sign" with tooltip
		- "Receives copy" changed to "Needs to approve" with tooltip
		- "Approver" changed to "Needs to view" with tooltip
		- "Viewer" changed to "Receives copy" with tooltip
- Enhanced select dropdown options with icons and tooltips for different
recipient roles in the `AddTemplatePlaceholderRecipients` component.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Timur Ercan <timur.ercan31@gmail.com>
2024-04-17 19:36:54 +07:00
8fe67e167c fix: duplicate templates (#1104)
## Description

Fix the issue where duplicate templates can be created

## Changes Made

- Prevent duplicate templates by correctly disabling elements
- Clear the PDF when the form is reset
- General UI changes to new template dialog for consistency
- Add description for new template dialog
- Add close button for new template dialog

## Testing Performed

Manual testing

## Checklist

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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced new components in the template creation dialog for better
usability: `DialogClose`, `DialogDescription`, and `DialogFooter`.
	- Enhanced file upload handling and display.
- Added a cancel button and a create template button to the dialog
footer for improved navigation.

- **Refactor**
- Updated the dialog content and form layout for a more intuitive user
experience.
	- Refactored form structure and labels for clarity.

- **Removed Features**
	- Removed the `Label` component from the dialog.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-17 19:13:54 +07:00
18b39eb538 chore: fix text padding (#1107)
**Description:**

This PR fixes the text padding 

**Before:**

<img width="639" alt="Screenshot 2024-04-17 at 4 10 51 AM"
src="https://github.com/documenso/documenso/assets/23498248/a46fdb12-4ec6-4084-af3a-ae794e535e6f">

**After:**

<img width="680" alt="Screenshot 2024-04-17 at 4 09 44 AM"
src="https://github.com/documenso/documenso/assets/23498248/0b9c6e8c-4116-4bde-a19d-f907fb93ad5d">


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **Style**
- Improved the layout by adding top margin to the message display on the
signing completion page.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-17 12:39:35 +02:00
3bc9b5ada0 chore: fix text padding 2024-04-17 04:08:41 +05:30
1126fe4bff chore: added filename extension check 2024-04-17 03:52:59 +05:30
db9899d293 fix: duplicate modal instances from hotkey activation (#1058)
## Description
Currently, when the command menu is opened using the Command+K hotkey,
two modals are getting rendered.
This is because the modals are mounted in two components: header and
desktop-nav. Upon triggering the hotkey, both modals are rendered.

## Related Issue
#1032 

## Changes Made
The changes I made are in the desktop nav component. If the desktop nav
receives the command menu state value and the state setter function, it
will trigger only that. If not, it will trigger the state setter that is
defined in the desktop nav. This way, we are preventing the modal from
mounting two times.

## Testing Performed
- Tested behaviour of command menu in the portal
- Tested on browsers chrome, arc, safari, chrome, firefox

## Checklist
- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced the navigation experience by integrating command menu state
management directly within the `DesktopNav` component, allowing for
smoother interactions and control.
- **Refactor**
- Simplified the handling of the command menu by removing the
`CommandMenu` component and managing its functionality within
`DesktopNav`.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: David Nguyen <davidngu28@gmail.com>
2024-04-16 19:42:28 +07:00
0eeccfd643 feat: add myself as signer (#1091)
## Description

This PR introduces the ability to add oneself as a signer by simply
clicking a button, rather than filling the details manually.

### "Add Myself" in the document creation flow


https://github.com/documenso/documenso/assets/25515812/0de762e3-563a-491f-a742-9078bf1d627d

### "Add Myself" in the document template creation flow


https://github.com/documenso/documenso/assets/25515812/775bae01-3f5a-4b24-abbf-a47b14ec594a


## Related Issue

Addresses
[#113](https://github.com/documenso/backlog-internal/issues/113)

## Changes Made

Added a new button that grabs the details of the logged-in user and
fills the fields *(email, name, and role)* automatically when clicked.

## Testing Performed

Tested the changes locally through the UI.

## Checklist

- [x] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [x] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced the ability for users to add themselves as signers within
documents seamlessly.
- **Enhancements**
- Improved form handling logic to accommodate new self-signer
functionality.
- Enhanced user interface elements to support the addition of self as a
signer, including a new "Add myself" button and disabling input fields
during the process.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-16 15:18:06 +07:00
aa4b6f1723 feat: updated mobile header (#1004)
**Description:**

- Updated mobile header with respect to latest designs 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added a new `showText` property to the `MenuSwitcher` component to
control text visibility.
- Added a `textSectionClassName` property to the `AvatarWithText`
component for conditional text section styling.
- Updated the `CommandDialog` and `DialogContent` components with new
positioning and styling properties.

- **Style Updates**
- Adjusted text size responsiveness in the `Hero` component for various
screen sizes.
- Modified text truncation and input styling in the `Widget` component.
- Changed the width of the `SheetContent` element in `MobileNavigation`
and adjusted footer layout.

- **Documentation**
  - Added instructions for certificate placement in `SIGNING.md`.

- **Refactor**
- Standardized type imports across various components and utilities for
improved type checking.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Adithya Krishna <adithya@documenso.com>
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
Co-authored-by: David Nguyen <davidngu28@gmail.com>
2024-04-15 15:52:34 +07:00
c8a09099a3 fix: mask recipient token (#1051)
The searchDocuments function is used for the shortcuts commands, afaik.
The function returns the documents that match the user query (if any),
alongside all their recipients.

The reason for that is so it can build the path for the document. E.g.
if you're the document owner, the document path will be
`..../documents/{id}`. But if you're a signer for example, the document
path (link) will be `..../sign/{token}`.

So instead of doing that on the frontend, I moved it to the backend.

At least that's what I understood. If I'm wrong, please correct me.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Enhanced the `CommandMenu` component to simplify search result
generation and improve document link management based on user roles.
- **Refactor**
- Updated document search logic to include recipient token masking and
refined document mapping.
- **Style**
	- Minor formatting improvement in document routing code.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-04-15 14:29:56 +07:00
0f87dc047b fix: swagger documentation authentication (#1037)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit


- **Refactor**
- Enhanced the API specification generation process to include operation
IDs, security schemes, and security definitions more efficiently.


<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2024-04-15 14:27:46 +07:00
80c758fb62 chore: audit log menu item label (#1102) 2024-04-12 20:37:08 +07:00
7705dbae0c feat: add document log page link (#1099)
## Description

Adds a link from the document page view to the document page log view

<img width="289" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/335af85a-26c3-4849-a54e-25eb62373574">
2024-04-11 15:04:36 +07:00
8b58f10cbe feat: add cta on complete page (#1028)
![CleanShot 2024-03-18 at 11 45
40](https://github.com/documenso/documenso/assets/25515812/ae3b88de-359d-4019-866a-a76097bbb0fe)
![CleanShot 2024-03-18 at 11 46
25](https://github.com/documenso/documenso/assets/25515812/b5ff7078-623e-476c-8800-17d14bc8efa9)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a "Claim Account" feature allowing new users to sign up by
providing their name, email, and password.
- Enhanced user experience for both logged-in and non-logged-in users
with improved UI/UX and additional functionality.

- **Enhancements**
- Implemented form validation and error handling for a smoother sign-up
process.
	- Integrated analytics to track user actions during account claiming.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
Co-authored-by: David Nguyen <davidngu28@gmail.com>
2024-04-11 10:09:04 +03:00
fe1f0e6a76 fix: remove status widget for now (#1098)
- removing widget since stability fix seems somehow worse in prod
2024-04-10 18:25:56 +02:00
a82975fd78 chore: keep import until fix or complete remove 2024-04-10 18:24:32 +02:00
a4967f19e8 fix: remove status widget for now 2024-04-10 18:22:46 +02:00
a311869c9b fix: status widget rerendering (#1097)
---
name: Pull Request
about: Submit changes to the project for review and inclusion
---

## Description

Should fix the rerendering of the status widget 
## Related Issue

<!--- If this pull request is related to a specific issue, reference it
here using #issue_number. -->
<!--- For example, "Fixes #123" or "Addresses #456". -->

## Changes Made

<!--- Provide a summary of the changes made in this pull request. -->
<!--- Include any relevant technical details or architecture changes.
-->

- Change 1
- Change 2
- ...

## Testing Performed

<!--- Describe the testing that you have performed to validate these
changes. -->
<!--- Include information about test cases, testing environments, and
results. -->

- Tested feature X in scenario Y.
- Ran unit tests for component Z.
- Tested on browsers A, B, and C.
- ...

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [ ] I have tested these changes locally and they work as expected.
- [ ] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [ ] I have followed the project's coding style guidelines.
- [ ] I have addressed the code review feedback from the previous
submission, if applicable.

## Additional Notes

<!--- Provide any additional context or notes for the reviewers. -->
<!--- This might include details about design decisions, potential
concerns, or anything else relevant. -->
2024-04-10 17:52:20 +02:00
6f3cea52e8 fix: handle older cert data 2024-04-10 22:34:14 +07:00
732827f81d Merge branch 'main' into main 2024-04-10 17:32:32 +02:00
bfff1234bb fix: handle older cert data 2024-04-10 22:32:25 +07:00
93a149d637 fix: handle older cert data 2024-04-10 22:10:20 +07:00
f7ae3104ea fix: status widget rerendering 2024-04-10 17:05:22 +02:00
64870f22b9 feat: add certificate and audit log pdfs (#1092) 2024-04-10 20:42:21 +07:00
12e4bc918d fix: marketing header darkmode (#1096)
Co-authored-by: Adithya Krishna <aadithya794@gmail.com>
2024-04-10 16:30:11 +03:00
e36763a85d feat: update marketing banner (#1095)
![CleanShot 2024-04-10 at 15 51
27](https://github.com/documenso/documenso/assets/25515812/d2ad275c-4e68-42f2-8882-a20129c0b0bd)
2024-04-10 16:07:14 +03:00
0bc9c590a7 fix: use ts-match 2024-04-10 20:03:01 +07:00
4d4dfd3c5f fix: implement review feedback, resolve build errors 2024-04-10 17:39:16 +07:00
c9b4915fc8 fix: remove hardcoded ids 2024-04-10 15:30:04 +07:00
110f9bae12 feat: add certificate and audit log pdfs 2024-04-10 15:13:18 +07:00
147 changed files with 4215 additions and 1230 deletions

View File

@ -41,7 +41,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Cache Docker layers - name: Cache Docker layers
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }} key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

@ -33,9 +33,9 @@ jobs:
- uses: ./.github/actions/cache-build - uses: ./.github/actions/cache-build
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v3

View File

@ -33,7 +33,7 @@ jobs:
- name: Run Playwright tests - name: Run Playwright tests
run: npm run ci run: npm run ci
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v4
if: always() if: always()
with: with:
name: test-results name: test-results

View File

@ -27,7 +27,7 @@ jobs:
- name: Check Assigned User's Issue Count - name: Check Assigned User's Issue Count
id: parse-comment id: parse-comment
uses: actions/github-script@v5 uses: actions/github-script@v6
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |

25
.github/workflows/issue-labeler.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Auto Label Assigned Issues
on:
issues:
types: [assigned]
jobs:
label-when-assigned:
runs-on: ubuntu-latest
steps:
- name: Label issue
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const issue = context.issue;
// To run only on issues and not on PR
if (github.context.payload.issue.pull_request === undefined) {
const labelResponse = await github.rest.issues.addLabels({
owner: issue.owner,
repo: issue.repo,
issue_number: issue.number,
labels: ['status: assigned']
});
}

View File

@ -17,5 +17,5 @@ jobs:
issue_number: context.issue.number, issue_number: context.issue.number,
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
labels: ["needs triage"] labels: ["status: triage"]
}) })

View File

@ -2,14 +2,14 @@ name: 'PR Review Reminder'
on: on:
pull_request: pull_request:
types: ['opened', 'reopened', 'ready_for_review', 'review_requested'] types: ['opened', 'ready_for_review']
permissions: permissions:
pull-requests: write pull-requests: write
jobs: jobs:
checkPRs: checkPRs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested') if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout

View File

@ -12,7 +12,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@v4 - uses: actions/stale@v5
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 90 days-before-pr-stale: 90

View File

@ -17,7 +17,8 @@ For the digital signature of your documents you need a signing certificate in .p
`openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt` `openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt`
4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**) 4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**)
5. Place the certificate `/apps/web/resources/certificate.p12`
5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created)
## Docker ## Docker

View File

@ -22,7 +22,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
const config = { const config = {
experimental: { experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'), outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'], serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
serverActions: { serverActions: {
bodySizeLimit: '50mb', bodySizeLimit: '50mb',
}, },

View File

@ -1,4 +1,5 @@
import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types'; import type { TClaimPlanRequestSchema } from './types';
import { ZClaimPlanResponseSchema } from './types';
export const claimPlan = async ({ export const claimPlan = async ({
name, name,

View File

@ -47,7 +47,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
> >
{showProfilesAnnouncementBar && ( {showProfilesAnnouncementBar && (
<div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5"> <div className="relative inline-flex w-full items-center justify-center overflow-hidden bg-[#e7f3df] px-4 py-2.5">
<div className="text-foreground text-center text-sm font-medium"> <div className="text-black text-center text-sm font-medium">
Claim your documenso public profile username now!{' '} Claim your documenso public profile username now!{' '}
<span className="hidden font-semibold md:inline">documenso.com/u/yourname</span> <span className="hidden font-semibold md:inline">documenso.com/u/yourname</span>
<div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block"> <div className="mt-1.5 block md:ml-4 md:mt-0 md:inline-block">

View File

@ -55,6 +55,7 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
cursor={{ fill: 'hsl(var(--primary) / 10%)' }} cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/> />
<Bar <Bar
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
dataKey={metricKey as string} dataKey={metricKey as string}
maxBarSize={60} maxBarSize={60}
fill="hsl(var(--primary))" fill="hsl(var(--primary))"

View File

@ -13,6 +13,7 @@ export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => { export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => {
const formattedData = data.map((item) => ({ const formattedData = data.map((item) => ({
amount: Number(item.amount), amount: Number(item.amount),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
date: formatMonth(item.date as string), date: formatMonth(item.date as string),
})); }));

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { import {

View File

@ -2,13 +2,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { Variants, motion } from 'framer-motion'; import type { Variants } from 'framer-motion';
import { motion } from 'framer-motion';
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 { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card'; import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
import { TOSSFriendsSchema } from './schema'; import type { TOSSFriendsSchema } from './schema';
const ContainerVariants: Variants = { const ContainerVariants: Variants = {
initial: { initial: {

View File

@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64'; import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client'; import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
} }
try { try {
const putFileData = await putFile(uploadedFile.file); const putFileData = await putPdfFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({ const documentToken = await createSinglePlayerDocument({
documentData: { documentData: {
@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
expired: null, expired: null,
signedAt: null, signedAt: null,
readStatus: 'OPENED', readStatus: 'OPENED',
documentDeletedAt: null,
signingStatus: 'NOT_SIGNED', signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT', sendStatus: 'NOT_SENT',
role: 'SIGNER', role: 'SIGNER',

View File

@ -1,4 +1,4 @@
import { MetadataRoute } from 'next'; import type { MetadataRoute } from 'next';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url';

View File

@ -1,4 +1,4 @@
import { MetadataRoute } from 'next'; import type { MetadataRoute } from 'next';
import { allBlogPosts, allGenericPages } from 'contentlayer/generated'; import { allBlogPosts, allGenericPages } from 'contentlayer/generated';

View File

@ -13,7 +13,7 @@ import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
import { StatusWidgetContainer } from './status-widget-container'; // import { StatusWidgetContainer } from './status-widget-container';
export type FooterProps = HTMLAttributes<HTMLDivElement>; export type FooterProps = HTMLAttributes<HTMLDivElement>;
@ -65,9 +65,9 @@ export const Footer = ({ className, ...props }: FooterProps) => {
))} ))}
</div> </div>
<div className="mt-6"> {/* <div className="mt-6">
<StatusWidgetContainer /> <StatusWidgetContainer />
</div> </div> */}
</div> </div>
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8"> <div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">

View File

@ -96,7 +96,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
variants={HeroTitleVariants} variants={HeroTitleVariants}
initial="initial" initial="initial"
animate="animate" animate="animate"
className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]" className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]"
> >
Document signing, Document signing,
<span className="block" /> finally open source. <span className="block" /> finally open source.

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import Image from 'next/image'; import Image from 'next/image';

View File

@ -6,7 +6,7 @@ import { StatusWidget } from './status-widget';
export function StatusWidgetContainer() { export function StatusWidgetContainer() {
return ( return (
<Suspense fallback={<StatusWidgetFallback />}> <Suspense fallback={<StatusWidgetFallback />}>
<StatusWidget /> <StatusWidget slug="documenso-status" />
</Suspense> </Suspense>
); );
} }

View File

@ -1,7 +1,6 @@
import { use, useMemo } from 'react'; import { memo, use } from 'react';
import type { Status } from '@openstatus/react'; import { type Status, getStatus } from '@openstatus/react';
import { getStatus } from '@openstatus/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -45,9 +44,8 @@ const getStatusLevel = (level: Status) => {
}[level]; }[level];
}; };
export function StatusWidget() { export const StatusWidget = memo(function StatusWidget({ slug }: { slug: string }) {
const getStatusMemoized = useMemo(async () => getStatus('documenso-status'), []); const { status } = use(getStatus(slug));
const { status } = use(getStatusMemoized);
const level = getStatusLevel(status); const level = getStatusLevel(status);
return ( return (
@ -72,4 +70,4 @@ export function StatusWidget() {
</span> </span>
</a> </a>
); );
} });

View File

@ -346,7 +346,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
{signatureText && ( {signatureText && (
<p <p
className={cn( className={cn(
'text-foreground text-4xl font-semibold [font-family:var(--font-caveat)]', 'text-foreground truncate text-4xl font-semibold [font-family:var(--font-caveat)]',
)} )}
> >
{signatureText} {signatureText}
@ -360,7 +360,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
> >
<Input <Input
id="signatureText" id="signatureText"
className="text-foreground placeholder:text-muted-foreground border-none p-0 text-sm focus-visible:ring-0" className="text-foreground placeholder:text-muted-foreground truncate border-none p-0 text-sm focus-visible:ring-0"
placeholder="Draw or type name here" placeholder="Draw or type name here"
disabled={isSubmitting} disabled={isSubmitting}
{...register('signatureText', { {...register('signatureText', {

View File

@ -1,5 +1,5 @@
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { FieldError } from 'react-hook-form'; import type { FieldError } from 'react-hook-form';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';

View File

@ -1,4 +1,4 @@
import { SVGAttributes } from 'react'; import type { SVGAttributes } from 'react';
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>; export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;

View File

@ -3,7 +3,7 @@
import * as React from 'react'; import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes'; import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { ThemeProviderProps } from 'next-themes/dist/types'; import type { ThemeProviderProps } from 'next-themes/dist/types';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) { export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; return <NextThemesProvider {...props}>{children}</NextThemesProvider>;

View File

@ -23,7 +23,7 @@ const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined, output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: { experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'), outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'], serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign', 'playwright'],
serverActions: { serverActions: {
bodySizeLimit: '50mb', bodySizeLimit: '50mb',
}, },

View File

@ -4,13 +4,22 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Copy, Download, Edit, Loader, MoreHorizontal, Share, Trash2 } from 'lucide-react'; import {
Copy,
Download,
Edit,
Loader,
MoreHorizontal,
ScrollTextIcon,
Share,
Trash2,
} from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; import type { Document, Recipient, Team, TeamEmail, User } from '@documenso/prisma/client';
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';
import { import {
@ -32,7 +41,7 @@ export type DocumentPageViewDropdownProps = {
Recipient: Recipient[]; Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'>; team?: Pick<Team, 'id' | 'url'> & { teamEmail: TeamEmail | null };
}; };
export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => { export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDropdownProps) => {
@ -50,9 +59,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
const isOwner = document.User.id === session.user.id; const isOwner = document.User.id === session.user.id;
const isDraft = document.status === DocumentStatus.DRAFT; const isDraft = document.status === DocumentStatus.DRAFT;
const isDeleted = document.deletedAt !== null;
const isComplete = document.status === DocumentStatus.COMPLETED; const isComplete = document.status === DocumentStatus.COMPLETED;
const isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && document.team?.url === team.url; const isCurrentTeamDocument = team && document.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@ -106,12 +116,22 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem asChild>
<Link href={`${documentsPath}/${document.id}/logs`}>
<ScrollTextIcon className="mr-2 h-4 w-4" />
Audit Log
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}> <DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> <DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
@ -138,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
/> />
</DropdownMenuContent> </DropdownMenuContent>
{isDocumentDeletable && ( <DeleteDocumentDialog
<DeleteDocumentDialog id={document.id}
id={document.id} status={document.status}
status={document.status} documentTitle={document.title}
documentTitle={document.title} open={isDeleteDialogOpen}
open={isDeleteDialogOpen} canManageDocument={canManageDocument}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
/> />
)}
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DuplicateDocumentDialog <DuplicateDocumentDialog
id={document.id} id={document.id}

View File

@ -8,17 +8,20 @@ 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 { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getCompletedFieldsForDocument } from '@documenso/lib/server-only/field/get-completed-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 { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { Team } from '@documenso/prisma/client'; import type { Team, TeamEmail } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { import {
DocumentStatus as DocumentStatusComponent, DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP, FRIENDLY_STATUS_MAP,
@ -34,7 +37,7 @@ export type DocumentPageViewProps = {
params: { params: {
id: string; id: string;
}; };
team?: Team; team?: Team & { teamEmail: TeamEmail | null };
}; };
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => { export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
@ -83,11 +86,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
documentMeta.password = securePassword; documentMeta.password = securePassword;
} }
const recipients = await getRecipientsForDocument({ const [recipients, completedFields] = await Promise.all([
documentId, getRecipientsForDocument({
teamId: team?.id, documentId,
userId: user.id, teamId: team?.id,
}); userId: user.id,
}),
getCompletedFieldsForDocument({
documentId,
}),
]);
const documentWithRecipients = { const documentWithRecipients = {
...document, ...document,
@ -118,11 +126,17 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom"> <StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span> <span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div> </div>
)} )}
{document.deletedAt && <Badge variant="destructive">Document deleted</Badge>}
</div> </div>
</div> </div>
@ -148,6 +162,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
</CardContent> </CardContent>
</Card> </Card>
{document.status === DocumentStatus.PENDING && (
<DocumentReadOnlyFields
fields={completedFields}
documentMeta={document.documentMeta || undefined}
/>
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5"> <div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6"> <div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6"> <section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">

View File

@ -92,7 +92,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip recipients={recipients} position="bottom"> <StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>{recipients.length} Recipient(s)</span> <span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip> </StackAvatarsWithTooltip>
</div> </div>

View File

@ -1,7 +1,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { ChevronLeft, DownloadIcon } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
@ -10,7 +10,6 @@ import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
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 { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Recipient, Team } from '@documenso/prisma/client'; import type { Recipient, Team } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { Card } from '@documenso/ui/primitives/card'; import { Card } from '@documenso/ui/primitives/card';
import { import {
@ -19,6 +18,8 @@ import {
} from '~/components/formatter/document-status'; } from '~/components/formatter/document-status';
import { DocumentLogsDataTable } from './document-logs-data-table'; import { DocumentLogsDataTable } from './document-logs-data-table';
import { DownloadAuditLogButton } from './download-audit-log-button';
import { DownloadCertificateButton } from './download-certificate-button';
export type DocumentLogsPageViewProps = { export type DocumentLogsPageViewProps = {
params: { params: {
@ -132,15 +133,13 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
</div> </div>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end"> <div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<Button variant="outline" className="mr-2 w-full sm:w-auto"> <DownloadCertificateButton
<DownloadIcon className="mr-1.5 h-4 w-4" /> className="mr-2"
Download certificate documentId={document.id}
</Button> documentStatus={document.status}
/>
<Button className="w-full sm:w-auto"> <DownloadAuditLogButton documentId={document.id} />
<DownloadIcon className="mr-1.5 h-4 w-4" />
Download PDF
</Button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,74 @@
'use client';
import { DownloadIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadAuditLogButtonProps = {
className?: string;
documentId: number;
};
export const DownloadAuditLogButton = ({ className, documentId }: DownloadAuditLogButtonProps) => {
const { toast } = useToast();
const { mutateAsync: downloadAuditLogs, isLoading } =
trpc.document.downloadAuditLogs.useMutation();
const onDownloadAuditLogsClick = async () => {
try {
const { url } = await downloadAuditLogs({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: 'Something went wrong',
description: 'Sorry, we were unable to download the audit logs. Please try again later.',
variant: 'destructive',
});
}
};
return (
<Button
className={cn('w-full sm:w-auto', className)}
loading={isLoading}
onClick={() => void onDownloadAuditLogsClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Audit Logs
</Button>
);
};

View File

@ -0,0 +1,82 @@
'use client';
import { DownloadIcon } from 'lucide-react';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadCertificateButtonProps = {
className?: string;
documentId: number;
documentStatus: DocumentStatus;
};
export const DownloadCertificateButton = ({
className,
documentId,
documentStatus,
}: DownloadCertificateButtonProps) => {
const { toast } = useToast();
const { mutateAsync: downloadCertificate, isLoading } =
trpc.document.downloadCertificate.useMutation();
const onDownloadCertificatesClick = async () => {
try {
const { url } = await downloadCertificate({ documentId });
const iframe = Object.assign(document.createElement('iframe'), {
src: url,
});
Object.assign(iframe.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
});
const onLoaded = () => {
if (iframe.contentDocument?.readyState === 'complete') {
iframe.contentWindow?.print();
iframe.contentWindow?.addEventListener('afterprint', () => {
document.body.removeChild(iframe);
});
}
};
// When the iframe has loaded, print the iframe and remove it from the dom
iframe.addEventListener('load', onLoaded);
document.body.appendChild(iframe);
onLoaded();
} catch (error) {
console.error(error);
toast({
title: 'Something went wrong',
description: 'Sorry, we were unable to download the certificate. Please try again later.',
variant: 'destructive',
});
}
};
return (
<Button
className={cn('w-full sm:w-auto', className)}
loading={isLoading}
variant="outline"
disabled={documentStatus !== DocumentStatus.COMPLETED}
onClick={() => void onDownloadCertificatesClick()}
>
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}
Download Certificate
</Button>
);
};

View File

@ -15,7 +15,6 @@ import {
Pencil, Pencil,
Share, Share,
Trash2, Trash2,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
@ -45,7 +44,7 @@ export type DataTableActionDropdownProps = {
Recipient: Recipient[]; Recipient: Recipient[];
team: Pick<Team, 'id' | 'url'> | null; team: Pick<Team, 'id' | 'url'> | null;
}; };
team?: Pick<Team, 'id' | 'url'>; team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
}; };
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => { export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
@ -67,8 +66,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
// 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 isDocumentDeletable = isOwner;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);
const documentsPath = formatDocumentsPath(team?.url); const documentsPath = formatDocumentsPath(team?.url);
@ -107,14 +106,14 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger data-testid="document-table-action-btn">
<MoreHorizontal className="text-muted-foreground h-5 w-5" /> <MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount> <DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel> <DropdownMenuLabel>Action</DropdownMenuLabel>
{recipient && recipient?.role !== RecipientRole.CC && ( {!isDraft && recipient && recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild> <DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}> <Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && ( {recipient?.role === RecipientRole.VIEWER && (
@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild> <DropdownMenuItem disabled={!canManageDocument || isComplete} asChild>
<Link href={`${documentsPath}/${row.id}/edit`}> <Link href={`${documentsPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Edit
@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
Duplicate Duplicate
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled> {/* No point displaying this if there's no functionality. */}
{/* <DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" /> <XCircle className="mr-2 h-4 w-4" />
Void Void
</DropdownMenuItem> </DropdownMenuItem> */}
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> <DropdownMenuItem
onClick={() => setDeleteDialogOpen(true)}
disabled={Boolean(!canManageDocument && team?.teamEmail)}
>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete {canManageDocument ? 'Delete' : 'Hide'}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel> <DropdownMenuLabel>Share</DropdownMenuLabel>
@ -186,16 +189,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
/> />
</DropdownMenuContent> </DropdownMenuContent>
{isDocumentDeletable && ( <DeleteDocumentDialog
<DeleteDocumentDialog id={row.id}
id={row.id} status={row.status}
status={row.status} documentTitle={row.title}
documentTitle={row.title} open={isDeleteDialogOpen}
open={isDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} teamId={team?.id}
teamId={team?.id} canManageDocument={canManageDocument}
/> />
)}
{isDuplicateDialogOpen && ( {isDuplicateDialogOpen && (
<DuplicateDocumentDialog <DuplicateDocumentDialog
id={row.id} id={row.id}

View File

@ -29,7 +29,7 @@ export type DocumentsDataTableProps = {
} }
>; >;
showSenderColumn?: boolean; showSenderColumn?: boolean;
team?: Pick<Team, 'id' | 'url'>; team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
}; };
export const DocumentsDataTable = ({ export const DocumentsDataTable = ({
@ -76,7 +76,12 @@ export const DocumentsDataTable = ({
{ {
header: 'Recipient', header: 'Recipient',
accessorKey: 'recipient', accessorKey: 'recipient',
cell: ({ row }) => <StackAvatarsWithTooltip recipients={row.original.Recipient} />, cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.Recipient}
documentStatus={row.original.status}
/>
),
}, },
{ {
header: 'Status', header: 'Status',

View File

@ -2,8 +2,11 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { match } from 'ts-pattern';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = {
status: DocumentStatus; status: DocumentStatus;
documentTitle: string; documentTitle: string;
teamId?: number; teamId?: number;
canManageDocument: boolean;
}; };
export const DeleteDocumentDialog = ({ export const DeleteDocumentDialog = ({
@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({
status, status,
documentTitle, documentTitle,
teamId, teamId,
canManageDocument,
}: DeleteDocumentDialogProps) => { }: DeleteDocumentDialogProps) => {
const router = useRouter(); const router = useRouter();
@ -83,47 +88,82 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}> <Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle> <DialogTitle>Are you sure?</DialogTitle>
<DialogDescription> <DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be You are about to {canManageDocument ? 'delete' : 'hide'}{' '}
permanently deleted. <strong>"{documentTitle}"</strong>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{status !== DocumentStatus.DRAFT && ( {canManageDocument ? (
<div className="mt-4"> <Alert variant="warning" className="-mt-1">
<Input {match(status)
type="text" .with(DocumentStatus.DRAFT, () => (
value={inputValue} <AlertDescription>
onChange={onInputChange} Please note that this action is <strong>irreversible</strong>. Once confirmed,
placeholder="Type 'delete' to confirm" this document will be permanently deleted.
/> </AlertDescription>
</div> ))
.with(DocumentStatus.PENDING, () => (
<AlertDescription>
<p>
Please note that this action is <strong>irreversible</strong>.
</p>
<p className="mt-1">Once confirmed, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>Document will be permanently deleted</li>
<li>Document signing process will be cancelled</li>
<li>All inserted signatures will be voided</li>
<li>All recipients will be notified</li>
</ul>
</AlertDescription>
))
.with(DocumentStatus.COMPLETED, () => (
<AlertDescription>
<p>By deleting this document, the following will occur:</p>
<ul className="mt-0.5 list-inside list-disc">
<li>The document will be hidden from your account</li>
<li>Recipients will still retain their copy of the document</li>
</ul>
</AlertDescription>
))
.exhaustive()}
</Alert>
) : (
<Alert variant="warning" className="-mt-1">
<AlertDescription>
Please contact support if you would like to revert this action.
</AlertDescription>
</Alert>
)}
{status !== DocumentStatus.DRAFT && canManageDocument && (
<Input
type="text"
value={inputValue}
onChange={onInputChange}
placeholder="Type 'delete' to confirm"
/>
)} )}
<DialogFooter> <DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4"> <Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Button Cancel
type="button" </Button>
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button <Button
type="button" type="button"
loading={isLoading} loading={isLoading}
onClick={onDelete} onClick={onDelete}
disabled={!isDeleteEnabled} disabled={!isDeleteEnabled && canManageDocument}
variant="destructive" variant="destructive"
className="flex-1" >
> {canManageDocument ? 'Delete' : 'Hide'}
Delete </Button>
</Button>
</div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -41,7 +41,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
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 senderIds = parseToIntegerArray(searchParams.senderIds ?? ''); const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
const currentTeam = team ? { id: team.id, url: team.url } : undefined; const currentTeam = team
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
: undefined;
const getStatOptions: GetStatsInput = { const getStatOptions: GetStatsInput = {
user, user,

View File

@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
})); }));
return ( return (
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"> <div
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
data-testid="empty-document-state"
>
<Icon className="h-12 w-12" strokeWidth={1.5} /> <Icon className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center"> <div className="text-center">

View File

@ -10,8 +10,9 @@ 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 { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
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 { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
try { try {
setIsLoading(true); setIsLoading(true);
const { type, data } = await putFile(file); const { type, data } = await putPdfFile(file);
const { id: documentDataId } = await createDocumentData({ const { id: documentDataId } = await createDocumentData({
type, type,
@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
}); });
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`); router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (error) { } catch (err) {
console.error(error); const error = AppError.parseError(err);
if (error instanceof TRPCClientError) { console.error(err);
if (error.code === 'INVALID_DOCUMENT_FILE') {
toast({
title: 'Invalid file',
description: 'You cannot upload encrypted PDFs',
variant: 'destructive',
});
} else if (err instanceof TRPCClientError) {
toast({ toast({
title: 'Error', title: 'Error',
description: error.message, description: err.message,
variant: 'destructive', variant: 'destructive',
}); });
} else { } else {

View File

@ -0,0 +1,9 @@
import React from 'react';
export type PublicProfileSettingsLayout = {
children: React.ReactNode;
};
export default function PublicProfileSettingsLayout({ children }: PublicProfileSettingsLayout) {
return <div className="col-span-12 md:col-span-9">{children}</div>;
}

View File

@ -0,0 +1,41 @@
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Switch } from '@documenso/ui/primitives/switch';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { LinkTemplatesForm } from '~/components/forms/link-templates';
import { PublicProfileForm } from '~/components/forms/public-profile';
export const metadata: Metadata = {
title: 'Public profile',
};
export default async function PublicProfileSettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<SettingsHeader
title="Public profile"
subtitle="You can choose to enable/disable your profile for public view"
className="justify mb-8"
hideDivider
>
<div className="flex flex-row">
<label className="mr-2 text-white" htmlFor="hide">
Hide
</label>
<Switch />
<label className="ml-2 text-white" htmlFor="show">
Show
</label>
</div>
</SettingsHeader>
<PublicProfileForm className="mb-8 max-w-xl" user={user} />
<LinkTemplatesForm />
</div>
);
}

View File

@ -141,6 +141,8 @@ export const EditTemplateForm = ({
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddTemplatePlaceholderFormSubmit} onSubmit={onAddTemplatePlaceholderFormSubmit}
// Todo: Add when we setup template settings.
isTemplateOwnerEnterprise={false}
/> />
<AddTemplateFieldsFormPartial <AddTemplateFieldsFormPartial

View File

@ -58,7 +58,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
]); ]);
return ( return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8"> <div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80"> <Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" /> <ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates Templates
@ -73,7 +73,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
</div> </div>
<EditTemplateForm <EditTemplateForm
className="mt-8" className="mt-6"
template={template} template={template}
user={user} user={user}
recipients={templateRecipients} recipients={templateRecipients}

View File

@ -12,13 +12,16 @@ import * as z from 'zod';
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 { base64 } from '@documenso/lib/universal/base64'; import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
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 { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
@ -34,7 +37,6 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZCreateTemplateFormSchema = z.object({ const ZCreateTemplateFormSchema = z.object({
@ -61,8 +63,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
resolver: zodResolver(ZCreateTemplateFormSchema), resolver: zodResolver(ZCreateTemplateFormSchema),
}); });
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } = const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false); const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>(); const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
@ -97,7 +98,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const file: File = uploadedFile.file; const file: File = uploadedFile.file;
try { try {
const { type, data } = await putFile(file); const { type, data } = await putPdfFile(file);
const { id: templateDocumentDataId } = await createDocumentData({ const { id: templateDocumentDataId } = await createDocumentData({
type, type,
@ -140,6 +141,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
useEffect(() => { useEffect(() => {
if (!showNewTemplateDialog) { if (!showNewTemplateDialog) {
form.reset(); form.reset();
setUploadedFile(null);
} }
}, [form, showNewTemplateDialog]); }, [form, showNewTemplateDialog]);
@ -154,20 +156,23 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
<DialogContent className="w-full max-w-xl"> <DialogContent className="w-full max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="mb-4">New Template</DialogTitle> <DialogTitle>New Template</DialogTitle>
<DialogDescription>
Templates allow you to quickly generate documents with pre-filled recipients and fields.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div> <Form {...form}>
<Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4"> <fieldset disabled={form.formState.isSubmitting} className="flex flex-col gap-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name your template</FormLabel> <FormLabel>Template name</FormLabel>
<FormControl> <FormControl>
<Input id="email" type="text" className="bg-background mt-1.5" {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
@ -180,55 +185,57 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
)} )}
/> />
<div> <div className="mt-1.5">
<Label htmlFor="template">Upload a Document</Label> {uploadedFile ? (
<Card gradient className="h-[40vh]">
<CardContent className="flex h-full flex-col items-center justify-center p-2">
<button
onClick={() => resetForm()}
title="Remove Template"
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<X className="h-6 w-6" />
<span className="sr-only">Remove Template</span>
</button>
<div className="my-3"> <div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
{uploadedFile ? ( <div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<Card gradient className="h-[40vh]"> <div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<CardContent className="flex h-full flex-col items-center justify-center p-2"> <div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<button </div>
onClick={() => resetForm()}
title="Remove Template"
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<X className="h-6 w-6" />
<span className="sr-only">Remove Template</span>
</button>
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm"> <p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" /> Uploaded Document
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" /> </p>
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</div>
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium"> <span className="text-muted-foreground/80 mt-1 text-sm">
Uploaded Document {uploadedFile.file.name}
</p> </span>
</CardContent>
<span className="text-muted-foreground/80 mt-1 text-sm"> </Card>
{uploadedFile.file.name} ) : (
</span> <DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
</CardContent> )}
</Card>
) : (
<DocumentDropzone
className="mt-1.5 h-[40vh]"
onDrop={onFileDrop}
type="template"
/>
)}
</div>
</div> </div>
<div className="flex w-full justify-end"> <DialogFooter>
<Button loading={isCreatingTemplate} type="submit"> <DialogClose asChild>
Create Template <Button type="button" variant="secondary">
Cancel
</Button>
</DialogClose>
<Button
loading={form.formState.isSubmitting}
disabled={!uploadedFile}
type="submit"
>
Create template
</Button> </Button>
</div> </DialogFooter>
</form> </fieldset>
</Form> </form>
</div> </Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -1,14 +1,21 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react'; import { InfoIcon, Plus } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod'; import * as z from 'zod';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
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 { Checkbox } from '@documenso/ui/primitives/checkbox';
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
@ -19,24 +26,59 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons'; import type { Toast } from '@documenso/ui/primitives/use-toast';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team'; import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z.object({ const ZAddRecipientsForNewDocumentSchema = z
recipients: z.array( .object({
z.object({ sendDocument: z.boolean(),
email: z.string().email(), recipients: z.array(
name: z.string(), z.object({
role: z.nativeEnum(RecipientRole), id: z.number(),
}), email: z.string().email(),
), name: z.string(),
}); }),
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>; type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@ -54,35 +96,33 @@ export function UseTemplateDialog({
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const [open, setOpen] = useState(false);
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const { const form = useForm<TAddRecipientsForNewDocumentSchema>({
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema), resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: { defaultValues: {
recipients: sendDocument: false,
recipients.length > 0 recipients: recipients.map((recipient) => {
? recipients.map((recipient) => ({ const isRecipientEmailPlaceholder = recipient.email.match(
nativeId: recipient.id, TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
formId: String(recipient.id), );
name: recipient.name,
email: recipient.email, const isRecipientNamePlaceholder = recipient.name.match(
role: recipient.role, TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
})) );
: [
{ return {
name: '', id: recipient.id,
email: '', name: !isRecipientNamePlaceholder ? recipient.name : '',
role: RecipientRole.SIGNER, email: !isRecipientEmailPlaceholder ? recipient.email : '',
}, };
], }),
}, },
}); });
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } = const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation(); trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => { const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
@ -91,6 +131,7 @@ export function UseTemplateDialog({
templateId, templateId,
teamId: team?.id, teamId: team?.id,
recipients: data.recipients, recipients: data.recipients,
sendDocument: data.sendDocument,
}); });
toast({ toast({
@ -101,23 +142,35 @@ export function UseTemplateDialog({
router.push(`${documentRootPath}/${id}`); router.push(`${documentRootPath}/${id}`);
} catch (err) { } catch (err) {
toast({ const error = AppError.parseError(err);
const toastPayload: Toast = {
title: 'Error', title: 'Error',
description: 'An error occurred while creating document from template.', description: 'An error occurred while creating document from template.',
variant: 'destructive', variant: 'destructive',
}); };
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = 'The document was created but could not be sent to recipients.';
}
toast(toastPayload);
} }
}; };
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
const { fields: formRecipients } = useFieldArray({ const { fields: formRecipients } = useFieldArray({
control, control: form.control,
name: 'recipients', name: 'recipients',
}); });
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return ( return (
<Dialog> <Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="cursor-pointer"> <Button className="cursor-pointer">
<Plus className="-ml-1 mr-2 h-4 w-4" /> <Plus className="-ml-1 mr-2 h-4 w-4" />
@ -126,121 +179,110 @@ export function UseTemplateDialog({
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Document Recipients</DialogTitle> <DialogTitle>Create document from template</DialogTitle>
<DialogDescription>Add the recipients to create the template with.</DialogDescription> <DialogDescription>
{recipients.length === 0
? 'A draft document will be created'
: 'Add the recipients to create the document with'}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col space-y-4">
{formRecipients.map((recipient, index) => (
<div
key={recipient.id}
data-native-id={recipient.id}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller <Form {...form}>
control={control} <form onSubmit={form.handleSubmit(onSubmit)}>
name={`recipients.${index}.email`} <fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
render={({ field }) => ( <div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
<Input {formRecipients.map((recipient, index) => (
id={`recipient-${recipient.id}-email`} <div className="flex w-full flex-row space-x-4" key={recipient.id}>
type="email" <FormField
className="bg-background mt-2" control={form.control}
disabled={isSubmitting} name={`recipients.${index}.email`}
{...field} render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email</FormLabel>}
<FormControl>
<Input {...field} placeholder={recipients[index].email || 'Email'} />
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
)}
/>
</div>
<div className="flex-1"> <FormField
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label> control={form.control}
name={`recipients.${index}.name`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel>Name</FormLabel>}
<Controller <FormControl>
control={control} <Input {...field} placeholder={recipients[index].name || 'Name'} />
name={`recipients.${index}.name`} </FormControl>
render={({ field }) => ( <FormMessage />
<Input </FormItem>
id={`recipient-${recipient.id}-name`} )}
type="text"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
/> />
)} </div>
/> ))}
</div> </div>
<div className="w-[60px]"> {recipients.length > 0 && (
<Controller <div className="mt-4 flex flex-row items-center">
control={control} <FormField
name={`recipients.${index}.role`} control={form.control}
render={({ field: { value, onChange } }) => ( name="sendDocument"
<Select value={value} onValueChange={(x) => onChange(x)}> render={({ field }) => (
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger> <FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="sendDocument"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
<SelectContent className="" align="end"> <label
<SelectItem value={RecipientRole.SIGNER}> className="text-muted-foreground ml-2 flex items-center text-sm"
<div className="flex items-center"> htmlFor="sendDocument"
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span> >
Signer Send document
</div> <Tooltip>
</SelectItem> <TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<SelectItem value={RecipientRole.CC}> <TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<div className="flex items-center"> <p>
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span> The document will be immediately sent to recipients if this is
Receives copy checked.
</div> </p>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}> <p>Otherwise, the document will be created as a draft.</p>
<div className="flex items-center"> </TooltipContent>
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span> </Tooltip>
Approver </label>
</div> </div>
</SelectItem> </FormItem>
)}
/>
</div>
)}
<SelectItem value={RecipientRole.VIEWER}> <DialogFooter>
<div className="flex items-center"> <DialogClose asChild>
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span> <Button type="button" variant="secondary">
Viewer Close
</div> </Button>
</SelectItem> </DialogClose>
</SelectContent>
</Select>
)}
/>
</div>
<div className="w-full"> <Button type="submit" loading={form.formState.isSubmitting}>
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} /> {form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'}
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} /> </Button>
</div> </DialogFooter>
</div> </fieldset>
))} </form>
</div> </Form>
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button
type="button"
loading={isCreatingDocumentFromTemplate}
disabled={isCreatingDocumentFromTemplate}
onClick={onCreateDocumentFromTemplate}
>
Create Document
</Button>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -0,0 +1,89 @@
'use client';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { UAParser } from 'ua-parser-js';
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { LocaleDate } from '~/components/formatter/locale-date';
export type AuditLogDataTableProps = {
logs: TDocumentAuditLog[];
};
const dateFormat: DateTimeFormatOptions = {
...DateTime.DATETIME_SHORT,
hourCycle: 'h12',
};
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
const parser = new UAParser();
const uppercaseFistLetter = (text: string) => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
return (
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Browser</TableHead>
</TableRow>
</TableHeader>
<TableBody className="print:text-xs">
{logs.map((log, i) => (
<TableRow className="break-inside-avoid" key={i}>
<TableCell>
<LocaleDate format={dateFormat} date={log.createdAt} />
</TableCell>
<TableCell>
{log.name || log.email ? (
<div>
{log.name && (
<p className="break-all" title={log.name}>
{log.name}
</p>
)}
{log.email && (
<p className="text-muted-foreground break-all" title={log.email}>
{log.email}
</p>
)}
</div>
) : (
<p>N/A</p>
)}
</TableCell>
<TableCell>
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
</TableCell>
<TableCell>{log.ipAddress}</TableCell>
<TableCell>
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};

View File

@ -0,0 +1,139 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Logo } from '~/components/branding/logo';
import { LocaleDate } from '~/components/formatter/locale-date';
import { AuditLogDataTable } from './data-table';
type AuditLogProps = {
searchParams: {
d: string;
};
};
export default async function AuditLog({ searchParams }: AuditLogProps) {
const { d } = searchParams;
if (typeof d !== 'string' || !d) {
return redirect('/');
}
const rawDocumentId = decryptSecondaryData(d);
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
return redirect('/');
}
const documentId = Number(rawDocumentId);
const document = await getEntireDocument({
id: documentId,
}).catch(() => null);
if (!document) {
return redirect('/');
}
const { data: auditLogs } = await findDocumentAuditLogs({
documentId: documentId,
userId: document.userId,
perPage: 100_000,
});
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">Version History</h1>
</div>
<Card>
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
<p>
<span className="font-medium">Document ID</span>
<span className="mt-1 block break-words">{document.id}</span>
</p>
<p>
<span className="font-medium">Enclosed Document</span>
<span className="mt-1 block break-words">{document.title}</span>
</p>
<p>
<span className="font-medium">Status</span>
<span className="mt-1 block">{document.deletedAt ? 'DELETED' : document.status}</span>
</p>
<p>
<span className="font-medium">Owner</span>
<span className="mt-1 block break-words">
{document.User.name} ({document.User.email})
</span>
</p>
<p>
<span className="font-medium">Created At</span>
<span className="mt-1 block">
<LocaleDate date={document.createdAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
</span>
</p>
<p>
<span className="font-medium">Last Updated</span>
<span className="mt-1 block">
<LocaleDate date={document.updatedAt} format="yyyy-mm-dd hh:mm:ss a (ZZZZ)" />
</span>
</p>
<p>
<span className="font-medium">Time Zone</span>
<span className="mt-1 block break-words">
{document.documentMeta?.timezone ?? 'N/A'}
</span>
</p>
<div>
<p className="font-medium">Recipients</p>
<ul className="mt-1 list-inside list-disc">
{document.Recipient.map((recipient) => (
<li key={recipient.id}>
<span className="text-muted-foreground">
[{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}]
</span>{' '}
{recipient.name} ({recipient.email})
</li>
))}
</ul>
</div>
</CardContent>
</Card>
<Card className="mt-8">
<CardContent className="p-0">
<AuditLogDataTable logs={auditLogs} />
</CardContent>
</Card>
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<Logo className="max-h-6 print:max-h-4" />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,299 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_SIGNING_REASONS,
} from '@documenso/lib/constants/recipient-roles';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { FieldType } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Logo } from '~/components/branding/logo';
import { LocaleDate } from '~/components/formatter/locale-date';
type SigningCertificateProps = {
searchParams: {
d: string;
};
};
const FRIENDLY_SIGNING_REASONS = {
['__OWNER__']: 'I am the owner of this document',
...RECIPIENT_ROLE_SIGNING_REASONS,
};
export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
const { d } = searchParams;
if (typeof d !== 'string' || !d) {
return redirect('/');
}
const rawDocumentId = decryptSecondaryData(d);
if (!rawDocumentId || isNaN(Number(rawDocumentId))) {
return redirect('/');
}
const documentId = Number(rawDocumentId);
const document = await getEntireDocument({
id: documentId,
}).catch(() => null);
if (!document) {
return redirect('/');
}
const auditLogs = await getDocumentCertificateAuditLogs({
id: documentId,
});
const isOwner = (email: string) => {
return email.toLowerCase() === document.User.email.toLowerCase();
};
const getDevice = (userAgent?: string | null) => {
if (!userAgent) {
return 'Unknown';
}
const parser = new UAParser(userAgent);
parser.setUA(userAgent);
const result = parser.getResult();
return `${result.os.name} - ${result.browser.name} ${result.browser.version}`;
};
const getAuthenticationLevel = (recipientId: number) => {
const recipient = document.Recipient.find((recipient) => recipient.id === recipientId);
if (!recipient) {
return 'Unknown';
}
const extractedAuthMethods = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
.with('ACCOUNT', () => 'Account Re-Authentication')
.with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
.with('PASSKEY', () => 'Passkey Re-Authentication')
.with('EXPLICIT_NONE', () => 'Email')
.with(null, () => null)
.exhaustive();
if (!authLevel) {
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
.with('ACCOUNT', () => 'Account Authentication')
.with(null, () => 'Email')
.exhaustive();
}
return authLevel;
};
const getRecipientAuditLogs = (recipientId: number) => {
return {
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED
].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED &&
log.data.recipientId === recipientId,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED
].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
log.data.recipientId === recipientId,
),
};
};
const getRecipientSignatureField = (recipientId: number) => {
return document.Recipient.find((recipient) => recipient.id === recipientId)?.Field.find(
(field) => field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE,
);
};
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">Signing Certificate</h1>
</div>
<Card>
<CardContent className="p-0">
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>Signer Events</TableHead>
<TableHead>Signature</TableHead>
<TableHead>Details</TableHead>
{/* <TableHead>Security</TableHead> */}
</TableRow>
</TableHeader>
<TableBody className="print:text-xs">
{document.Recipient.map((recipient, i) => {
const logs = getRecipientAuditLogs(recipient.id);
const signature = getRecipientSignatureField(recipient.id);
return (
<TableRow key={i} className="print:break-inside-avoid">
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
<div className="break-all">{recipient.email}</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">Authentication Level:</span>{' '}
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
</p>
</TableCell>
<TableCell truncate={false} className="w-[min-content] align-top">
{signature ? (
<>
<div
className="inline-block rounded-lg p-1"
style={{
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
}}
>
<img
src={`${signature.Signature?.signatureImageAsBase64}`}
alt="Signature"
className="max-h-12 max-w-full"
/>
</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">Signature ID:</span>{' '}
<span className="block font-mono uppercase">
{signature.secondaryId}
</span>
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">IP Address:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
</span>
</p>
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
<span className="font-medium">Device:</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
</span>
</p>
</>
) : (
<p className="text-muted-foreground">N/A</p>
)}
</TableCell>
<TableCell truncate={false} className="w-[min-content] align-top">
<div className="space-y-1">
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Sent:</span>{' '}
<span className="inline-block">
{logs.EMAIL_SENT[0] ? (
<LocaleDate
date={logs.EMAIL_SENT[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Viewed:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_OPENED[0] ? (
<LocaleDate
date={logs.DOCUMENT_OPENED[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Signed:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0] ? (
<LocaleDate
date={logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt}
format="yyyy-MM-dd hh:mm:ss a (ZZZZ)"
/>
) : (
'Unknown'
)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">Reason:</span>{' '}
<span className="inline-block">
{isOwner(recipient.email)
? FRIENDLY_SIGNING_REASONS['__OWNER__']
: FRIENDLY_SIGNING_REASONS[recipient.role]}
</span>
</p>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
Signing certificate provided by:
</p>
<Logo className="max-h-6 print:max-h-4" />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,155 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type ClaimAccountProps = {
defaultName: string;
defaultEmail: string;
trigger?: React.ReactNode;
};
export const ZClaimAccountFormSchema = z
.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1),
password: ZPasswordSchema,
})
.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',
path: ['password'],
},
);
export type TClaimAccountFormSchema = z.infer<typeof ZClaimAccountFormSchema>;
export const ClaimAccount = ({ defaultName, defaultEmail }: ClaimAccountProps) => {
const analytics = useAnalytics();
const { toast } = useToast();
const router = useRouter();
const { mutateAsync: signup } = trpc.auth.signup.useMutation();
const form = useForm<TClaimAccountFormSchema>({
values: {
name: defaultName ?? '',
email: defaultEmail,
password: '',
},
resolver: zodResolver(ZClaimAccountFormSchema),
});
const onFormSubmit = async ({ name, email, password }: TClaimAccountFormSchema) => {
try {
await signup({ name, email, password });
router.push(`/unverified-account`);
toast({
title: 'Registration Successful',
description:
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
duration: 5000,
});
analytics.capture('App: User Claim Account', {
email,
timestamp: new Date().toISOString(),
});
} catch (error) {
if (error instanceof TRPCClientError && error.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: error.message,
variant: 'destructive',
});
} else {
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 (
<div className="mt-2 w-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="mt-4">
<FormField
name="name"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter your name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="email"
control={form.control}
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>Email address</FormLabel>
<FormControl>
<Input {...field} placeholder="Enter your email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
control={form.control}
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>Set a password</FormLabel>
<FormControl>
<PasswordInput {...field} placeholder="Pick a password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="mt-6 w-full" loading={form.formState.isSubmitting}>
Claim account
</Button>
</fieldset>
</form>
</Form>
</div>
);
};

View File

@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
import { CheckCircle2, Clock8 } from 'lucide-react'; import { CheckCircle2, Clock8 } from 'lucide-react';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { env } from 'next-runtime-env';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import signingCelebration from '@documenso/assets/images/signing-celebration.png';
@ -16,10 +17,13 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie
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 { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { truncateTitle } from '~/helpers/truncate-title'; import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page'; import { SigningAuthPageView } from '../signing-auth-page';
import { ClaimAccount } from './claim-account';
import { DocumentPreviewButton } from './document-preview-button'; import { DocumentPreviewButton } from './document-preview-button';
export type CompletedSigningPageProps = { export type CompletedSigningPageProps = {
@ -31,6 +35,8 @@ export type CompletedSigningPageProps = {
export default async function CompletedSigningPage({ export default async function CompletedSigningPage({
params: { token }, params: { token },
}: CompletedSigningPageProps) { }: CompletedSigningPageProps) {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
if (!token) { if (!token) {
return notFound(); return notFound();
} }
@ -79,96 +85,120 @@ export default async function CompletedSigningPage({
const sessionData = await getServerSession(); const sessionData = await getServerSession();
const isLoggedIn = !!sessionData?.user; const isLoggedIn = !!sessionData?.user;
const canSignUp = !isLoggedIn && NEXT_PUBLIC_DISABLE_SIGNUP !== 'true';
return ( return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44"> <div
{/* Card with recipient */} className={cn(
<SigningCard3D '-mx-4 flex flex-col items-center overflow-x-hidden px-4 pt-24 md:-mx-8 md:px-8 lg:pt-36 xl:pt-44',
name={recipientName} { 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
signature={signatures.at(0)} )}
signingCelebrationImage={signingCelebration} >
/> <div
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
canSignUp,
})}
>
<div
className={cn('flex flex-col items-center', {
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
})}
>
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="relative mt-6 flex w-full flex-col items-center"> {/* Card with recipient */}
{match({ status: document.status, deletedAt: document.deletedAt }) <SigningCard3D
.with({ status: DocumentStatus.COMPLETED }, () => ( name={recipientName}
<div className="text-documenso-700 flex items-center text-center"> signature={signatures.at(0)}
<CheckCircle2 className="mr-2 h-5 w-5" /> signingCelebrationImage={signingCelebration}
<span className="text-sm">Everyone has signed</span> />
</div>
))
.with({ deletedAt: null }, () => (
<div className="flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span>
</div>
))
.otherwise(() => (
<div className="flex items-center text-center text-red-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Document no longer available to sign</span>
</div>
))}
<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 Document
{recipient.role === RecipientRole.SIGNER && ' signed '} {recipient.role === RecipientRole.SIGNER && ' Signed '}
{recipient.role === RecipientRole.VIEWER && ' viewed '} {recipient.role === RecipientRole.VIEWER && ' Viewed '}
{recipient.role === RecipientRole.APPROVER && ' approved '} {recipient.role === RecipientRole.APPROVER && ' Approved '}
<span className="mt-1.5 block">"{truncatedTitle}"</span> </h2>
</h2>
{match({ status: document.status, deletedAt: document.deletedAt }) {match({ status: document.status, deletedAt: document.deletedAt })
.with({ status: DocumentStatus.COMPLETED }, () => ( .with({ status: DocumentStatus.COMPLETED }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> <div className="text-documenso-700 mt-4 flex items-center text-center">
Everyone has signed! You will receive an Email copy of the signed document. <CheckCircle2 className="mr-2 h-5 w-5" />
</p> <span className="text-sm">Everyone has signed</span>
)) </div>
.with({ deletedAt: null }, () => ( ))
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> .with({ deletedAt: null }, () => (
You will receive an Email copy of the signed document once everyone has signed. <div className="flex items-center mt-4 text-center text-blue-600">
</p> <Clock8 className="mr-2 h-5 w-5" />
)) <span className="text-sm">Waiting for others to sign</span>
.otherwise(() => ( </div>
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base"> ))
This document has been cancelled by the owner and is no longer available for others to .otherwise(() => (
sign. <div className="flex items-center text-center text-red-600">
</p> <Clock8 className="mr-2 h-5 w-5" />
))} <span className="text-sm">Document no longer available to sign</span>
</div>
))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4"> {match({ status: document.status, deletedAt: document.deletedAt })
<DocumentShareButton documentId={document.id} token={recipient.token} /> .with({ status: DocumentStatus.COMPLETED }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
Everyone has signed! You will receive an Email copy of the signed document.
</p>
))
.with({ deletedAt: null }, () => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
You will receive an Email copy of the signed document once everyone has signed.
</p>
))
.otherwise(() => (
<p className="text-muted-foreground/60 mt-2.5 max-w-[60ch] text-center text-sm font-medium md:text-base">
This document has been cancelled by the owner and is no longer available for others
to sign.
</p>
))}
{document.status === DocumentStatus.COMPLETED ? ( <div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<DocumentDownloadButton <DocumentShareButton documentId={document.id} token={recipient.token} />
className="flex-1"
fileName={document.title} {document.status === DocumentStatus.COMPLETED ? (
documentData={documentData} <DocumentDownloadButton
disabled={document.status !== DocumentStatus.COMPLETED} className="flex-1"
/> fileName={document.title}
) : ( documentData={documentData}
<DocumentPreviewButton disabled={document.status !== DocumentStatus.COMPLETED}
className="text-[11px]" />
title="Signatures will appear once the document has been completed" ) : (
documentData={documentData} <DocumentPreviewButton
/> className="text-[11px]"
)} title="Signatures will appear once the document has been completed"
documentData={documentData}
/>
)}
</div>
</div> </div>
{isLoggedIn ? ( {canSignUp && (
<div className={`flex max-w-xl flex-col items-center justify-center p-4 md:p-12`}>
<h2 className="mt-8 text-center text-xl font-semibold md:mt-0">
Need to sign documents?
</h2>
<p className="text-muted-foreground/60 mt-4 max-w-[55ch] text-center leading-normal">
Create your account and start using state-of-the-art document signing.
</p>
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
</div>
)}
{isLoggedIn && (
<Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36"> <Link href="/documents" className="text-documenso-700 hover:text-documenso-600 mt-36">
Go Back Home Go Back Home
</Link> </Link>
) : (
<p className="text-muted-foreground/60 mt-36 text-sm">
Want to send slick signing links like this one?{' '}
<Link
href="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
</Link>
</p>
)} )}
</div> </div>
</div> </div>

View File

@ -6,6 +6,7 @@ import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-c
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
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';
@ -37,7 +38,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient] = await Promise.all([ const [document, fields, recipient, completedFields] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
token, token,
userId: user?.id, userId: user?.id,
@ -45,9 +46,15 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
}).catch(() => null), }).catch(() => null),
getFieldsForToken({ token }), getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
getCompletedFieldsForToken({ token }),
]); ]);
if (!document || !document.documentData || !recipient) { if (
!document ||
!document.documentData ||
!recipient ||
document.status === DocumentStatus.DRAFT
) {
return notFound(); return notFound();
} }
@ -120,7 +127,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
signature={user?.email === recipient.email ? user.signature : undefined} signature={user?.email === recipient.email ? user.signature : undefined}
> >
<DocumentAuthProvider document={document} recipient={recipient} user={user}> <DocumentAuthProvider document={document} recipient={recipient} user={user}>
<SigningPageView recipient={recipient} document={document} fields={fields} /> <SigningPageView
recipient={recipient}
document={document}
fields={fields}
completedFields={completedFields}
/>
</DocumentAuthProvider> </DocumentAuthProvider>
</SigningProvider> </SigningProvider>
); );

View File

@ -4,12 +4,14 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
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';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { CompletedField } from '@documenso/lib/types/fields';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client'; import { FieldType, RecipientRole } 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';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { truncateTitle } from '~/helpers/truncate-title'; import { truncateTitle } from '~/helpers/truncate-title';
import { DateField } from './date-field'; import { DateField } from './date-field';
@ -23,9 +25,15 @@ export type SigningPageViewProps = {
document: DocumentAndSender; document: DocumentAndSender;
recipient: Recipient; recipient: Recipient;
fields: Field[]; fields: Field[];
completedFields: CompletedField[];
}; };
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => { export const SigningPageView = ({
document,
recipient,
fields,
completedFields,
}: SigningPageViewProps) => {
const truncatedTitle = truncateTitle(document.title); const truncatedTitle = truncateTitle(document.title);
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
</div> </div>
</div> </div>
<DocumentReadOnlyFields fields={completedFields} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}> <ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) => {fields.map((field) =>
match(field.type) match(field.type)

View File

@ -1,7 +1,10 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { GetTeamTokensResponse } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens'; import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -23,7 +26,24 @@ export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
const team = await getTeamByUrl({ userId: user.id, teamUrl }); const team = await getTeamByUrl({ userId: user.id, teamUrl });
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }); let tokens: GetTeamTokensResponse | null = null;
try {
tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
} catch (err) {
const error = AppError.parseError(err);
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<p className="text-muted-foreground mt-2 text-sm">
{match(error.code)
.with(AppErrorCode.UNAUTHORIZED, () => error.message)
.otherwise(() => 'Something went wrong.')}
</p>
</div>
);
}
return ( return (
<div> <div>

View File

@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
export default function SignatureDisclosure() { export default function SignatureDisclosure() {
return ( return (
<div> <div>
<article className="prose"> <article className="prose dark:prose-invert">
<h1>Electronic Signature Disclosure</h1> <h1>Electronic Signature Disclosure</h1>
<h2>Welcome</h2> <h2>Welcome</h2>

View File

@ -8,6 +8,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; 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 { DocumentStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -15,18 +16,21 @@ import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = { export type AvatarWithRecipientProps = {
recipient: Recipient; recipient: Recipient;
documentStatus: DocumentStatus;
}; };
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard(); const [, copy] = useCopyToClipboard();
const { toast } = useToast(); const { toast } = useToast();
const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null;
const onRecipientClick = () => { const onRecipientClick = () => {
if (!recipient.token) { if (!signingToken) {
return; return;
} }
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => { void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => {
toast({ toast({
title: 'Copied to clipboard', title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.', description: 'The signing link has been copied to your clipboard.',
@ -37,10 +41,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
return ( return (
<div <div
className={cn('my-1 flex items-center gap-2', { className={cn('my-1 flex items-center gap-2', {
'cursor-pointer hover:underline': recipient.token, 'cursor-pointer hover:underline': signingToken,
})} })}
role={recipient.token ? 'button' : undefined} role={signingToken ? 'button' : undefined}
title={recipient.token && 'Click to copy signing link for sending to recipient'} title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
onClick={onRecipientClick} onClick={onRecipientClick}
> >
<StackAvatar <StackAvatar
@ -49,16 +53,15 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)} fallbackText={recipientAbbreviation(recipient)}
/> />
<div>
<div <div
className="text-muted-foreground text-sm" className="text-muted-foreground text-sm"
title="Click to copy signing link for sending to recipient" title={signingToken ? 'Click to copy signing link for sending to recipient' : undefined}
> >
<p>{recipient.email} </p> <p>{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs"> <p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p> </p>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,33 +1,28 @@
'use client'; 'use client';
import { useRef, useState } from 'react';
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 { 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 { DocumentStatus, Recipient } from '@documenso/prisma/client';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { PopoverHover } from '@documenso/ui/primitives/popover';
import { AvatarWithRecipient } from './avatar-with-recipient'; import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar'; import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars'; import { StackAvatars } from './stack-avatars';
export type StackAvatarsWithTooltipProps = { export type StackAvatarsWithTooltipProps = {
documentStatus: DocumentStatus;
recipients: Recipient[]; recipients: Recipient[];
position?: 'top' | 'bottom'; position?: 'top' | 'bottom';
children?: React.ReactNode; children?: React.ReactNode;
}; };
export const StackAvatarsWithTooltip = ({ export const StackAvatarsWithTooltip = ({
documentStatus,
recipients, recipients,
position, position,
children, children,
}: StackAvatarsWithTooltipProps) => { }: StackAvatarsWithTooltipProps) => {
const [open, setOpen] = useState(false);
const isControlled = useRef(false);
const isMouseOverTimeout = useRef<NodeJS.Timeout | null>(null);
const waitingRecipients = recipients.filter( const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'waiting', (recipient) => getRecipientType(recipient) === 'waiting',
); );
@ -44,105 +39,74 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === 'unsigned', (recipient) => getRecipientType(recipient) === 'unsigned',
); );
const onMouseEnter = () => {
if (isMouseOverTimeout.current) {
clearTimeout(isMouseOverTimeout.current);
}
if (isControlled.current) {
return;
}
isMouseOverTimeout.current = setTimeout(() => {
setOpen((o) => (!o ? true : o));
}, 200);
};
const onMouseLeave = () => {
if (isMouseOverTimeout.current) {
clearTimeout(isMouseOverTimeout.current);
}
if (isControlled.current) {
return;
}
setTimeout(() => {
setOpen((o) => (o ? false : o));
}, 200);
};
const onOpenChange = (newOpen: boolean) => {
isControlled.current = newOpen;
setOpen(newOpen);
};
return ( return (
<Popover open={open} onOpenChange={onOpenChange}> <PopoverHover
<PopoverTrigger trigger={children || <StackAvatars recipients={recipients} />}
className="flex cursor-pointer" contentProps={{
onMouseEnter={onMouseEnter} className: 'flex flex-col gap-y-5 py-2',
onMouseLeave={onMouseLeave} side: position,
> }}
{children || <StackAvatars recipients={recipients} />} >
</PopoverTrigger> {completedRecipients.length > 0 && (
<div>
<PopoverContent <h1 className="text-base font-medium">Completed</h1>
side={position} {completedRecipients.map((recipient: Recipient) => (
onMouseEnter={onMouseEnter} <div key={recipient.id} className="my-1 flex items-center gap-2">
onMouseLeave={onMouseLeave} <StackAvatar
className="flex flex-col gap-y-5 py-2" first={true}
> key={recipient.id}
{completedRecipients.length > 0 && ( type={getRecipientType(recipient)}
<div> fallbackText={recipientAbbreviation(recipient)}
<h1 className="text-base font-medium">Completed</h1> />
{completedRecipients.map((recipient: Recipient) => ( <div className="">
<div key={recipient.id} className="my-1 flex items-center gap-2"> <p className="text-muted-foreground text-sm">{recipient.email}</p>
<StackAvatar <p className="text-muted-foreground/70 text-xs">
first={true} {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
key={recipient.id} </p>
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<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> ))}
)} </div>
)}
{waitingRecipients.length > 0 && ( {waitingRecipients.length > 0 && (
<div> <div>
<h1 className="text-base font-medium">Waiting</h1> <h1 className="text-base font-medium">Waiting</h1>
{waitingRecipients.map((recipient: Recipient) => ( {waitingRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} /> <AvatarWithRecipient
))} key={recipient.id}
</div> recipient={recipient}
)} documentStatus={documentStatus}
/>
))}
</div>
)}
{openedRecipients.length > 0 && ( {openedRecipients.length > 0 && (
<div> <div>
<h1 className="text-base font-medium">Opened</h1> <h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => ( {openedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} /> <AvatarWithRecipient
))} key={recipient.id}
</div> recipient={recipient}
)} documentStatus={documentStatus}
/>
))}
</div>
)}
{uncompletedRecipients.length > 0 && ( {uncompletedRecipients.length > 0 && (
<div> <div>
<h1 className="text-base font-medium">Uncompleted</h1> <h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => ( {uncompletedRecipients.map((recipient: Recipient) => (
<AvatarWithRecipient key={recipient.id} recipient={recipient} /> <AvatarWithRecipient
))} key={recipient.id}
</div> recipient={recipient}
)} documentStatus={documentStatus}
</PopoverContent> />
</Popover> ))}
</div>
)}
</PopoverHover>
); );
}; };

View File

@ -5,7 +5,6 @@ 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';
@ -18,7 +17,6 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION, DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META, SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc'; } from '@documenso/lib/constants/trpc';
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,
@ -71,7 +69,6 @@ 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();
@ -93,17 +90,6 @@ 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 [];
@ -111,10 +97,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
return searchDocumentsData.map((document) => ({ return searchDocumentsData.map((document) => ({
label: document.title, label: document.title,
path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient), path: document.path,
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '), value: document.value,
})); }));
}, [searchDocumentsData, isOwner, getSigningLink]); }, [searchDocumentsData]);
const currentPage = pages[pages.length - 1]; const currentPage = pages[pages.length - 1];

View File

@ -1,5 +1,3 @@
'use client';
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -12,8 +10,6 @@ import { getRootHref } from '@documenso/lib/utils/params';
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 { CommandMenu } from '../common/command-menu';
const navigationLinks = [ const navigationLinks = [
{ {
href: '/documents', href: '/documents',
@ -25,13 +21,14 @@ const navigationLinks = [
}, },
]; ];
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>; export type DesktopNavProps = HTMLAttributes<HTMLDivElement> & {
setIsCommandMenuOpen: (value: boolean) => void;
};
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => {
const pathname = usePathname(); const pathname = usePathname();
const params = useParams(); const params = useParams();
const [open, setOpen] = useState(false);
const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
const rootHref = getRootHref(params, { returnEmptyRootString: true }); const rootHref = getRootHref(params, { returnEmptyRootString: true });
@ -70,12 +67,10 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
))} ))}
</div> </div>
<CommandMenu open={open} onOpenChange={setOpen} />
<Button <Button
variant="outline" variant="outline"
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg" className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
onClick={() => setOpen((open) => !open)} onClick={() => setIsCommandMenuOpen(true)}
> >
<div className="flex items-center"> <div className="flex items-center">
<Search className="mr-2 h-5 w-5" /> <Search className="mr-2 h-5 w-5" />

View File

@ -58,7 +58,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
<Logo className="h-6 w-auto" /> <Logo className="h-6 w-auto" />
</Link> </Link>
<DesktopNav /> <DesktopNav setIsCommandMenuOpen={setIsCommandMenuOpen} />
<div className="flex gap-x-4 md:ml-8"> <div className="flex gap-x-4 md:ml-8">
<MenuSwitcher user={user} teams={teams} /> <MenuSwitcher user={user} teams={teams} />

View File

@ -93,7 +93,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
<Button <Button
data-testid="menu-switcher" data-testid="menu-switcher"
variant="none" variant="none"
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent" className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
> >
<AvatarWithText <AvatarWithText
avatarFallback={formatAvatarFallback(selectedTeam?.name)} avatarFallback={formatAvatarFallback(selectedTeam?.name)}
@ -102,12 +102,13 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
rightSideComponent={ rightSideComponent={
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" /> <ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
} }
textSectionClassName="hidden lg:flex"
/> />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className={cn('z-[60] ml-2 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')} className={cn('z-[60] ml-6 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
align="end" align="end"
forceMount forceMount
> >

View File

@ -46,7 +46,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
return ( return (
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}> <Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<SheetContent className="flex w-full max-w-[400px] flex-col"> <SheetContent className="flex w-full max-w-[350px] flex-col">
<Link href="/" onClick={handleMenuItemClick}> <Link href="/" onClick={handleMenuItemClick}>
<Image <Image
src={LogoImage} src={LogoImage}
@ -87,7 +87,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
</div> </div>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. All rights reserved. © {new Date().getFullYear()} Documenso, Inc. <br /> All rights reserved.
</p> </p>
</div> </div>
</SheetContent> </SheetContent>

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react'; import { Braces, CreditCard, Globe2, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -101,6 +101,18 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button> </Button>
</Link> </Link>
)} )}
<Link href="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2 className="mr-2 h-5 w-5" />
Public profile
</Button>
</Link>
</div> </div>
); );
}; };

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react'; import { Braces, CreditCard, Globe2, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -104,6 +104,18 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button> </Button>
</Link> </Link>
)} )}
<Link href="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2 className="mr-2 h-5 w-5" />
Public profile
</Button>
</Link>
</div> </div>
); );
}; };

View File

@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
import { P, match } from 'ts-pattern';
import {
DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { CompletedField } from '@documenso/lib/types/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { DocumentMeta } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PopoverHover } from '@documenso/ui/primitives/popover';
export type DocumentReadOnlyFieldsProps = {
fields: CompletedField[];
documentMeta?: DocumentMeta;
};
export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => {
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
const handleHideField = (fieldId: string) => {
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
return (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
<FieldRootContainer
field={field}
key={field.id}
cardClassName="border-gray-100/50 !shadow-none backdrop-blur-[1px] bg-background/90"
>
<div className="absolute -right-3 -top-3">
<PopoverHover
trigger={
<Avatar className="dark:border-border h-8 w-8 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
{extractInitials(field.Recipient.name || field.Recipient.email)}
</AvatarFallback>
</Avatar>
}
contentProps={{
className: 'flex w-fit flex-col py-2.5 text-sm',
}}
>
<p>
<span className="font-semibold">
{field.Recipient.name
? `${field.Recipient.name} (${field.Recipient.email})`
: field.Recipient.email}{' '}
</span>
inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()}
</p>
<Button
variant="outline"
className="mt-2.5 h-6 text-xs focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
>
Hide field
</Button>
</PopoverHover>
</div>
<div className="text-muted-foreground break-all text-sm">
{match(field)
.with({ type: FieldType.SIGNATURE }, (field) =>
field.Signature?.signatureImageAsBase64 ? (
<img
src={field.Signature.signatureImageAsBase64}
alt="Signature"
className="h-full w-full object-contain dark:invert"
/>
) : (
<p className="font-signature text-muted-foreground text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{field.Signature?.typedSignature}
</p>
),
)
.with(
{ type: P.union(FieldType.NAME, FieldType.TEXT, FieldType.EMAIL) },
() => field.customText,
)
.with({ type: FieldType.DATE }, () =>
convertToLocalSystemFormat(
field.customText,
documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
),
)
.with({ type: FieldType.FREE_SIGNATURE }, () => null)
.exhaustive()}
</div>
</FieldRootContainer>
),
)}
</ElementVisible>
);
};

View File

@ -0,0 +1,26 @@
'use client';
import { File } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export const LinkTemplatesForm = () => {
return (
<div className={cn('flex max-w-xl flex-row items-center justify-between')}>
<div>
<h3 className="text-lg font-medium">My templates</h3>
<p className="text-muted-foreground text-sm md:mt-2">
Create templates to display in your public profile
</p>
</div>
<div>
<Button type="submit" variant="outline" className="self-end p-4">
<File className="mr-2" /> Link template
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,182 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Copy } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZPublicProfileFormSchema = z.object({
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
bio: z
.string()
.trim()
.max(256, {
message: 'Bio cannot be longer than 256 characters.',
})
.optional(),
});
export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
export type PublicProfileFormProps = {
className?: string;
user: User;
};
export const PublicProfileForm = ({ className, user }: PublicProfileFormProps) => {
const router = useRouter();
const { toast } = useToast();
const form = useForm<TPublicProfileFormSchema>({
values: {
url: user.url || '',
},
resolver: zodResolver(ZPublicProfileFormSchema),
});
const watchedBio = form.watch('bio');
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ url, bio }: TPublicProfileFormSchema) => {
try {
await updatePublicProfile({
url,
bio,
});
toast({
title: 'Public profile updated',
description: 'Your public profile has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to sign you In. Please try again later.',
});
}
}
};
const handleCopyUrl = () => {
const profileUrl = `documenso.com/u/${user.url}`;
navigator.clipboard
.writeText(profileUrl)
.then(() => {
toast({
title: 'URL Copied',
description: 'The profile URL has been copied to your clipboard.',
duration: 3000,
});
})
.catch((err) => {
console.error('Failed to copy: ', err);
toast({
title: 'Error',
description: 'Failed to copy the URL to clipboard.',
variant: 'destructive',
duration: 3000,
});
});
};
return (
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-8" disabled={isSubmitting}>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile URL</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="bg-muted flex w-full items-center rounded pl-2">
<span className="text-xs">documenso.com/u/{user.url}</span>
<Button
variant="ghost"
onClick={handleCopyUrl}
className="flex items-center justify-center rounded p-2 hover:bg-gray-200"
aria-label="Copy URL"
>
<Copy className="h-5 w-5" />
</Button>
</div>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<>
<Textarea placeholder="Write about yourself..." {...field} />
<div className="text-muted-foreground text-left text-sm">
{256 - (watchedBio?.length || 0)} characters remaining
</div>
</>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isSubmitting} className="self-end">
{isSubmitting ? 'Saving changes...' : 'Save changes'}
</Button>
</form>
</Form>
);
};

View File

@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth'; import NextAuth from 'next-auth';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
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 { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@ -18,15 +20,29 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
error: '/signin', error: '/signin',
}, },
events: { events: {
signIn: async ({ user }) => { signIn: async ({ user: { id: userId } }) => {
await prisma.userSecurityAuditLog.create({ const [user] = await Promise.all([
data: { await prisma.user.findFirstOrThrow({
userId: user.id, where: {
ipAddress, id: userId,
userAgent, },
type: UserSecurityAuditLogType.SIGN_IN, }),
}, await prisma.userSecurityAuditLog.create({
}); data: {
userId,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
}),
]);
// Create the Stripe customer and attach it to the user if it doesn't exist.
if (user.customerId === null && IS_BILLING_ENABLED()) {
await getStripeCustomerByUser(user).catch((err) => {
console.error(err);
});
}
}, },
signOut: async ({ token }) => { signOut: async ({ token }) => {
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id; const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;

View File

@ -3,7 +3,7 @@ import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router'; import { appRouter } from '@documenso/trpc/server/router';
export const config = { export const config = {
maxDuration: 60, maxDuration: 120,
api: { api: {
bodyParser: { bodyParser: {
sizeLimit: '50mb', sizeLimit: '50mb',

85
package-lock.json generated
View File

@ -22,6 +22,7 @@
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"playwright": "1.43.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"
@ -4701,13 +4702,26 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@playwright/browser-chromium": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz",
"integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"playwright-core": "1.43.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.40.0", "version": "1.43.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz",
"integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==", "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"playwright": "1.40.0" "playwright": "1.43.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -4716,6 +4730,50 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/@playwright/test/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@playwright/test/node_modules/playwright": {
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz",
"integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==",
"dev": true,
"dependencies": {
"playwright-core": "1.43.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/@playwright/test/node_modules/playwright-core": {
"version": "1.43.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz",
"integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "5.4.2", "version": "5.4.2",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.4.2.tgz",
@ -17615,12 +17673,11 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.40.0", "version": "1.43.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz",
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==",
"dev": true,
"dependencies": { "dependencies": {
"playwright-core": "1.40.0" "playwright-core": "1.43.0"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@ -17633,10 +17690,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.40.0", "version": "1.43.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz",
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==",
"dev": true,
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
}, },
@ -17648,7 +17704,6 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"optional": true, "optional": true,
"os": [ "os": [
@ -24926,6 +24981,7 @@
"next-auth": "4.24.5", "next-auth": "4.24.5",
"oslo": "^0.17.0", "oslo": "^0.17.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"playwright": "1.43.0",
"react": "18.2.0", "react": "18.2.0",
"remeda": "^1.27.1", "remeda": "^1.27.1",
"stripe": "^12.7.0", "stripe": "^12.7.0",
@ -24933,6 +24989,7 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/browser-chromium": "1.43.0",
"@types/luxon": "^3.3.1" "@types/luxon": "^3.3.1"
} }
}, },

View File

@ -38,6 +38,7 @@
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"playwright": "1.43.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"
@ -59,4 +60,4 @@
"next": "14.0.3" "next": "14.0.3"
} }
} }
} }

View File

@ -11,6 +11,7 @@ import {
ZDeleteDocumentMutationSchema, ZDeleteDocumentMutationSchema,
ZDeleteFieldMutationSchema, ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema, ZDeleteRecipientMutationSchema,
ZDownloadDocumentSuccessfulSchema,
ZGetDocumentsQuerySchema, ZGetDocumentsQuerySchema,
ZSendDocumentForSigningMutationSchema, ZSendDocumentForSigningMutationSchema,
ZSuccessfulDocumentResponseSchema, ZSuccessfulDocumentResponseSchema,
@ -51,6 +52,17 @@ export const ApiContractV1 = c.router(
summary: 'Get a single document', summary: 'Get a single document',
}, },
downloadSignedDocument: {
method: 'GET',
path: '/api/v1/documents/:id/download',
responses: {
200: ZDownloadDocumentSuccessfulSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Download a signed document when the storage transport is S3',
},
createDocument: { createDocument: {
method: 'POST', method: 'POST',
path: '/api/v1/documents', path: '/api/v1/documents',

View File

@ -19,11 +19,14 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
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 { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import {
getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { ApiContractV1 } from './contract'; import { ApiContractV1 } from './contract';
@ -83,6 +86,68 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
} }
}), }),
downloadSignedDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params;
try {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
return {
status: 500,
body: {
message: 'Please make sure the storage transport is set to S3.',
},
};
}
const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
if (!document || !document.documentDataId) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
if (DocumentDataType.S3_PATH !== document.documentData.type) {
return {
status: 400,
body: {
message: 'Invalid document data type',
},
};
}
if (document.status !== DocumentStatus.COMPLETED) {
return {
status: 400,
body: {
message: 'Document is not completed yet.',
},
};
}
const { url } = await getPresignGetUrl(document.documentData.data);
return {
status: 200,
body: { downloadUrl: url },
};
} catch (err) {
return {
status: 500,
body: {
message: 'Error downloading the document. Please try again.',
},
};
}
}),
deleteDocument: authenticatedMiddleware(async (args, user, team) => { deleteDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params; const { id: documentId } = args.params;
@ -164,6 +229,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
requestMetadata: extractNextApiRequestMetadata(args.req), requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const recipients = await setRecipientsForDocument({ const recipients = await setRecipientsForDocument({
userId: user.id, userId: user.id,
teamId: team?.id, teamId: team?.id,
@ -214,7 +286,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`; const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const document = await createDocumentFromTemplate({ const document = await createDocumentFromTemplateLegacy({
templateId, templateId,
userId: user.id, userId: user.id,
teamId: team?.id, teamId: team?.id,
@ -231,7 +303,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
formValues: body.formValues, formValues: body.formValues,
}); });
const newDocumentData = await putFile({ const newDocumentData = await putPdfFile({
name: fileName, name: fileName,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled), arrayBuffer: async () => Promise.resolve(prefilled),
@ -259,10 +331,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await upsertDocumentMeta({ await upsertDocumentMeta({
documentId: document.id, documentId: document.id,
userId: user.id, userId: user.id,
subject: body.meta.subject, ...body.meta,
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
requestMetadata: extractNextApiRequestMetadata(args.req), requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
} }

View File

@ -2,16 +2,34 @@ import { generateOpenApi } from '@ts-rest/open-api';
import { ApiContractV1 } from './contract'; import { ApiContractV1 } from './contract';
export const OpenAPIV1 = generateOpenApi( export const OpenAPIV1 = Object.assign(
ApiContractV1, generateOpenApi(
{ ApiContractV1,
info: { {
title: 'Documenso API', info: {
version: '1.0.0', title: 'Documenso API',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.', version: '1.0.0',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
},
}, },
}, {
setOperationId: true,
},
),
{ {
setOperationId: true, components: {
securitySchemes: {
authorization: {
type: 'apiKey',
in: 'header',
name: 'Authorization',
},
},
},
security: [
{
authorization: [],
},
],
}, },
); );

View File

@ -53,6 +53,10 @@ export const ZUploadDocumentSuccessfulSchema = z.object({
key: z.string(), key: z.string(),
}); });
export const ZDownloadDocumentSuccessfulSchema = z.object({
downloadUrl: z.string(),
});
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>; export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
export const ZCreateDocumentMutationSchema = z.object({ export const ZCreateDocumentMutationSchema = z.object({

View File

@ -45,7 +45,7 @@ test.describe('[EE_ONLY]', () => {
await page await page
.getByRole('textbox', { name: 'Email', exact: true }) .getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com'); .fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2'); await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Display advanced settings. // Display advanced settings.
await page.getByLabel('Show advanced settings').click(); await page.getByLabel('Show advanced settings').click();
@ -82,7 +82,7 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
await page.getByPlaceholder('Name').fill('Recipient 1'); await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com'); await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2'); await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Advanced settings should not be visible for non EE users. // Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden(); await expect(page.getByLabel('Show advanced settings')).toBeHidden();

View File

@ -136,7 +136,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.getByPlaceholder('Name').fill('User 1'); await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com'); await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('User 2'); await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2');
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
@ -254,7 +254,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
await page.getByRole('button', { name: 'Sign' }).click(); await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`); await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('You have signed')).toBeVisible(); await expect(page.getByText('Document Signed')).toBeVisible();
// Check if document has been signed // Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token); const { status: completedStatus } = await getDocumentByToken(token);

View File

@ -8,6 +8,7 @@ import {
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication'; import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
@ -74,7 +75,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
email: sender.email, email: sender.email,
}); });
// open actions menu // Open document action menu.
await page await page
.locator('tr', { hasText: 'Document 1 - Completed' }) .locator('tr', { hasText: 'Document 1 - Completed' })
.getByRole('cell', { name: 'Download' }) .getByRole('cell', { name: 'Download' })
@ -115,7 +116,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
email: sender.email, email: sender.email,
}); });
// open actions menu // Open document action menu.
await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click(); await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click();
// delete document // delete document
@ -135,20 +136,11 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
}); });
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible(); await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
await page.goto(`/sign/${recipient.token}`);
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
await page.goto('/documents');
await page.waitForURL('/documents');
await apiSignout({ page }); await apiSignout({ page });
} }
}); });
test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => {
page,
}) => {
const { sender } = await seedDeleteDocumentsTestRequirements(); const { sender } = await seedDeleteDocumentsTestRequirements();
await apiSignin({ await apiSignin({
@ -156,11 +148,10 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
email: sender.email, email: sender.email,
}); });
// open actions menu // Open document action menu.
await page await page
.locator('tr', { hasText: 'Document 1 - Draft' }) .locator('tr', { hasText: 'Document 1 - Draft' })
.getByRole('cell', { name: 'Edit' }) .getByTestId('document-table-action-btn')
.getByRole('button')
.click(); .click();
// delete document // delete document
@ -169,4 +160,155 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional
await page.getByRole('button', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible(); await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting pending documents should permanently remove it', async ({ page }) => {
const { sender } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting completed documents as an owner should hide it from only the owner', async ({
page,
}) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
await apiSignin({
page,
email: sender.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 2);
// Sign into the recipient account.
await apiSignout({ page });
await apiSignin({
page,
email: recipients[0].email,
});
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
});
test('[DOCUMENTS]: deleting documents as a recipient should only hide it for them', async ({
page,
}) => {
const { sender, recipients } = await seedDeleteDocumentsTestRequirements();
const recipientA = recipients[0];
const recipientB = recipients[1];
await apiSignin({
page,
email: recipientA.email,
});
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
await page.getByRole('button', { name: 'Hide' }).click();
// Open document action menu.
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
await page.getByRole('button', { name: 'Hide' }).click();
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 0);
// Sign into the sender account.
await apiSignout({ page });
await apiSignin({
page,
email: sender.email,
});
// Check document counts for sender.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
// Sign into the other recipient account.
await apiSignout({ page });
await apiSignin({
page,
email: recipientB.email,
});
// Check document counts for other recipient.
await checkDocumentTabCount(page, 'Inbox', 1);
await checkDocumentTabCount(page, 'Pending', 0);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 0);
await checkDocumentTabCount(page, 'All', 2);
}); });

View File

@ -0,0 +1,17 @@
import type { Page } from '@playwright/test';
import { expect } from '@playwright/test';
export const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
await page.getByRole('tab', { name: tabName }).click();
if (tabName !== 'All') {
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
}
if (count === 0) {
await expect(page.getByTestId('empty-document-state')).toBeVisible();
return;
}
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
};

View File

@ -1,4 +1,3 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
@ -7,24 +6,10 @@ import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/se
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication'; import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
await page.getByRole('tab', { name: tabName }).click();
if (tabName !== 'All') {
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
}
if (count === 0) {
await expect(page.getByRole('main')).toContainText(`Nothing to do`);
return;
}
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
};
test('[TEAMS]: check team documents count', async ({ page }) => { test('[TEAMS]: check team documents count', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments(); const { team, teamMember2 } = await seedTeamDocuments();
@ -245,24 +230,6 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
await unseedTeam(team.url); await unseedTeam(team.url);
}); });
test('[TEAMS]: delete pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments();
await apiSignin({
page,
email: currentUser.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Pending', 1);
});
test('[TEAMS]: resend pending team document', async ({ page }) => { test('[TEAMS]: resend pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments(); const { team, teamMember2: currentUser } = await seedTeamDocuments();
@ -280,3 +247,125 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
await expect(page.getByRole('status')).toContainText('Document re-sent'); await expect(page.getByRole('status')).toContainText('Document re-sent');
}); });
test('[TEAMS]: delete draft team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Draft', 1);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});
test('[TEAMS]: delete pending team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await page.getByRole('row').getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Pending', 1);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 1);
await checkDocumentTabCount(page, 'Completed', 1);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});
test('[TEAMS]: delete completed team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
email: teamMember3.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
await page.getByRole('row').getByRole('button').nth(2).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await checkDocumentTabCount(page, 'Completed', 0);
// Should be hidden for all team members.
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Check document counts.
await checkDocumentTabCount(page, 'Inbox', 0);
await checkDocumentTabCount(page, 'Pending', 2);
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 2);
await checkDocumentTabCount(page, 'All', 4);
await apiSignout({ page });
}
await unseedTeam(team.url);
});

View File

@ -189,7 +189,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use personal template. // Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click(); await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/documents/); await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents'); await page.waitForURL('/documents');
@ -200,7 +207,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use team template. // Use team template.
await page.getByRole('button', { name: 'Use Template' }).click(); await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/); await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click(); await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`); await page.waitForURL(`/t/${team.url}/documents`);

View File

@ -5,7 +5,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
import { redis } from '@documenso/lib/server-only/redis'; import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { alphaid, nanoid } from '@documenso/lib/universal/id'; import { alphaid, nanoid } from '@documenso/lib/universal/id';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { import {
DocumentStatus, DocumentStatus,
@ -74,7 +74,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url), new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
).then(async (res) => res.arrayBuffer()); ).then(async (res) => res.arrayBuffer());
const { id: documentDataId } = await putFile({ const { id: documentDataId } = await putPdfFile({
name: 'Documenso Supporter Pledge.pdf', name: 'Documenso Supporter Pledge.pdf',
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(documentBuffer), arrayBuffer: async () => Promise.resolve(documentBuffer),

View File

@ -23,6 +23,10 @@ export const TemplateDocumentCancel = ({
<br />"{documentName}" <br />"{documentName}"
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400">
All signatures have been voided.
</Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">
You don't need to sign it anymore. You don't need to sign it anymore.
</Text> </Text>

View File

@ -11,6 +11,7 @@ export interface TemplateDocumentInviteProps {
signDocumentLink: string; signDocumentLink: string;
assetBaseUrl: string; assetBaseUrl: string;
role: RecipientRole; role: RecipientRole;
selfSigner: boolean;
} }
export const TemplateDocumentInvite = ({ export const TemplateDocumentInvite = ({
@ -19,6 +20,7 @@ export const TemplateDocumentInvite = ({
signDocumentLink, signDocumentLink,
assetBaseUrl, assetBaseUrl,
role, role,
selfSigner,
}: TemplateDocumentInviteProps) => { }: TemplateDocumentInviteProps) => {
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role]; const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
@ -28,8 +30,19 @@ export const TemplateDocumentInvite = ({
<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 {actionVerb.toLowerCase()} {selfSigner ? (
<br />"{documentName}" <>
{`Please ${actionVerb.toLowerCase()} your document`}
<br />
{`"${documentName}"`}
</>
) : (
<>
{`${inviterName} has invited you to ${actionVerb.toLowerCase()}`}
<br />
{`"${documentName}"`}
</>
)}
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">

View File

@ -22,6 +22,7 @@ import { TemplateFooter } from '../template-components/template-footer';
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & { export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
customBody?: string; customBody?: string;
role: RecipientRole; role: RecipientRole;
selfSigner?: boolean;
}; };
export const DocumentInviteEmailTemplate = ({ export const DocumentInviteEmailTemplate = ({
@ -32,10 +33,13 @@ export const DocumentInviteEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002', assetBaseUrl = 'http://localhost:3002',
customBody, customBody,
role, role,
selfSigner = false,
}: DocumentInviteEmailTemplateProps) => { }: DocumentInviteEmailTemplateProps) => {
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase(); const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
const previewText = `${inviterName} has invited you to ${action} ${documentName}`; const previewText = selfSigner
? `Please ${action} your document ${documentName}`
: `${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();
@ -71,6 +75,7 @@ export const DocumentInviteEmailTemplate = ({
signDocumentLink={signDocumentLink} signDocumentLink={signDocumentLink}
assetBaseUrl={assetBaseUrl} assetBaseUrl={assetBaseUrl}
role={role} role={role}
selfSigner={selfSigner}
/> />
</Section> </Section>
</Container> </Container>

View File

@ -21,6 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account. * Does not take any person or group properties into account.
*/ */
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = { export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_allow_encrypted_documents: false,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true', app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false, app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag. app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.

View File

@ -32,3 +32,10 @@ export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.VIEWER]: 'VIEW_REQUEST', [RecipientRole.VIEWER]: 'VIEW_REQUEST',
[RecipientRole.APPROVER]: 'APPROVE_REQUEST', [RecipientRole.APPROVER]: 'APPROVE_REQUEST',
} as const; } as const;
export const RECIPIENT_ROLE_SIGNING_REASONS = {
[RecipientRole.SIGNER]: 'I am a signer of this document',
[RecipientRole.APPROVER]: 'I am an approver of this document',
[RecipientRole.CC]: 'I am required to recieve a copy of this document',
[RecipientRole.VIEWER]: 'I am a viewer of this document',
} satisfies Record<keyof typeof RecipientRole, string>;

View File

@ -0,0 +1,2 @@
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;

View File

@ -1,4 +1,5 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { match } from 'ts-pattern';
import { z } from 'zod'; import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
@ -149,4 +150,24 @@ export class AppError extends Error {
return null; return null;
} }
} }
static toRestAPIError(err: unknown): {
status: 400 | 401 | 404 | 500;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = match(error.code)
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.otherwise(() => 500 as const);
return {
status,
body: {
message: status !== 500 ? error.message : 'Something went wrong',
},
};
}
} }

View File

@ -39,6 +39,7 @@
"next-auth": "4.24.5", "next-auth": "4.24.5",
"oslo": "^0.17.0", "oslo": "^0.17.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"playwright": "1.43.0",
"react": "18.2.0", "react": "18.2.0",
"remeda": "^1.27.1", "remeda": "^1.27.1",
"stripe": "^12.7.0", "stripe": "^12.7.0",
@ -46,6 +47,7 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.3.1" "@types/luxon": "^3.3.1",
"@playwright/browser-chromium": "1.43.0"
} }
} }

View File

@ -10,6 +10,14 @@ export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
id, id,
}, },
include: { include: {
documentMeta: true,
User: {
select: {
id: true,
name: true,
email: true,
},
},
Recipient: { Recipient: {
include: { include: {
Field: { Field: {

View File

@ -49,8 +49,8 @@ export const completeDocumentWithToken = async ({
const document = await getDocument({ token, documentId }); const document = await getDocument({ token, documentId });
if (document.status === DocumentStatus.COMPLETED) { if (document.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${document.id} has already been completed`); throw new Error(`Document ${document.id} must be pending`);
} }
if (document.Recipient.length === 0) { if (document.Recipient.length === 0) {
@ -137,7 +137,7 @@ export const completeDocumentWithToken = async ({
await sendPendingEmail({ documentId, recipientId: recipient.id }); await sendPendingEmail({ documentId, recipientId: recipient.id });
} }
const documents = await prisma.document.updateMany({ const haveAllRecipientsSigned = await prisma.document.findFirst({
where: { where: {
id: document.id, id: document.id,
Recipient: { Recipient: {
@ -146,13 +146,9 @@ export const completeDocumentWithToken = async ({
}, },
}, },
}, },
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
}); });
if (documents.count > 0) { if (haveAllRecipientsSigned) {
await sealDocument({ documentId: document.id, requestMetadata }); await sealDocument({ documentId: document.id, requestMetadata });
} }

View File

@ -6,6 +6,7 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render'; import { render } from '@documenso/email/render';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@ -27,110 +28,180 @@ export const deleteDocument = async ({
teamId, teamId,
requestMetadata, requestMetadata,
}: DeleteDocumentOptions) => { }: DeleteDocumentOptions) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new Error('User not found');
}
const document = await prisma.document.findUnique({ const document = await prisma.document.findUnique({
where: { where: {
id, id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
}, },
include: { include: {
Recipient: true, Recipient: true,
documentMeta: true, documentMeta: true,
User: true, team: {
select: {
members: true,
},
},
}, },
}); });
if (!document) { if (!document || (teamId !== undefined && teamId !== document.teamId)) {
throw new Error('Document not found'); throw new Error('Document not found');
} }
const { status, User: user } = document; const isUserOwner = document.userId === userId;
const isUserTeamMember = document.team?.members.some((member) => member.userId === userId);
const userRecipient = document.Recipient.find((recipient) => recipient.email === user.email);
// if the document is a draft, hard-delete if (!isUserOwner && !isUserTeamMember && !userRecipient) {
if (status === DocumentStatus.DRAFT) { throw new Error('Not allowed');
}
// Handle hard or soft deleting the actual document if user has permission.
if (isUserOwner || isUserTeamMember) {
await handleDocumentOwnerDelete({
document,
user,
requestMetadata,
});
}
// Continue to hide the document from the user if they are a recipient.
// Dirty way of doing this but it's faster than refetching the document.
if (userRecipient?.documentDeletedAt === null) {
await prisma.recipient
.update({
where: {
id: userRecipient.id,
},
data: {
documentDeletedAt: new Date().toISOString(),
},
})
.catch(() => {
// Do nothing.
});
}
// Return partial document for API v1 response.
return {
id: document.id,
userId: document.userId,
teamId: document.teamId,
title: document.title,
status: document.status,
documentDataId: document.documentDataId,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
completedAt: document.completedAt,
};
};
type HandleDocumentOwnerDeleteOptions = {
document: Document & {
Recipient: Recipient[];
documentMeta: DocumentMeta | null;
};
user: User;
requestMetadata?: RequestMetadata;
};
const handleDocumentOwnerDelete = async ({
document,
user,
requestMetadata,
}: HandleDocumentOwnerDeleteOptions) => {
if (document.deletedAt) {
return;
}
// Soft delete completed documents.
if (document.status === DocumentStatus.COMPLETED) {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
// Currently redundant since deleting a document will delete the audit logs.
// However may be useful if we disassociate audit lgos and documents if required.
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
documentId: id, documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user, user,
requestMetadata, requestMetadata,
data: { data: {
type: 'HARD', type: 'SOFT',
}, },
}), }),
}); });
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } }); return await tx.document.update({
where: {
id: document.id,
},
data: {
deletedAt: new Date().toISOString(),
},
});
}); });
} }
// if the document is pending, send cancellation emails to all recipients // Hard delete draft and pending documents.
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) { const deletedDocument = await prisma.$transaction(async (tx) => {
await Promise.all( // Currently redundant since deleting a document will delete the audit logs.
document.Recipient.map(async (recipient) => { // However may be useful if we disassociate audit logs and documents if required.
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
}
// If the document is not a draft, only soft-delete.
return await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
documentId: id, documentId: document.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user, user,
requestMetadata, requestMetadata,
data: { data: {
type: 'SOFT', type: 'HARD',
}, },
}), }),
}); });
return await tx.document.update({ return await tx.document.delete({
where: { where: {
id, id: document.id,
}, status: {
data: { not: DocumentStatus.COMPLETED,
deletedAt: new Date().toISOString(), },
}, },
}); });
}); });
// Send cancellation emails to recipients.
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
await mailer.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document Cancelled',
html: render(template),
text: render(template, { plainText: true }),
});
}),
);
return deletedDocument;
}; };

View File

@ -94,24 +94,65 @@ export const findDocuments = async ({
}; };
} }
const whereClause: Prisma.DocumentWhereInput = { let deletedFilter: Prisma.DocumentWhereInput = {
...termFilters,
...filters,
AND: { AND: {
OR: [ OR: [
{ {
status: ExtendedDocumentStatus.COMPLETED, userId: user.id,
deletedAt: null,
}, },
{ {
status: { Recipient: {
not: ExtendedDocumentStatus.COMPLETED, some: {
email: user.email,
documentDeletedAt: null,
},
}, },
deletedAt: null,
}, },
], ],
}, },
}; };
if (team) {
deletedFilter = {
AND: {
OR: team.teamEmail
? [
{
teamId: team.id,
deletedAt: null,
},
{
User: {
email: team.teamEmail.email,
},
deletedAt: null,
},
{
Recipient: {
some: {
email: team.teamEmail.email,
documentDeletedAt: null,
},
},
},
]
: [
{
teamId: team.id,
deletedAt: null,
},
],
},
};
}
const whereClause: Prisma.DocumentWhereInput = {
...termFilters,
...filters,
...deletedFilter,
};
if (period) { if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10); const daysAgo = parseInt(period.replace(/d$/, ''), 10);

View File

@ -0,0 +1,43 @@
import { prisma } from '@documenso/prisma';
import { DOCUMENT_AUDIT_LOG_TYPE, DOCUMENT_EMAIL_TYPE } from '../../types/document-audit-logs';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
export type GetDocumentCertificateAuditLogsOptions = {
id: number;
};
export const getDocumentCertificateAuditLogs = async ({
id,
}: GetDocumentCertificateAuditLogsOptions) => {
const rawAuditLogs = await prisma.documentAuditLog.findMany({
where: {
documentId: id,
type: {
in: [
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
],
},
},
});
const auditLogs = rawAuditLogs.map((log) => parseDocumentAuditLogData(log));
const groupedAuditLogs = {
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
),
[DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT]: auditLogs.filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT &&
log.data.emailType !== DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED,
),
} as const;
return groupedAuditLogs;
};

View File

@ -72,6 +72,7 @@ type GetCountsOption = {
const getCounts = async ({ user, createdAt }: GetCountsOption) => { const getCounts = async ({ user, createdAt }: GetCountsOption) => {
return Promise.all([ return Promise.all([
// Owner counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -84,6 +85,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
deletedAt: null, deletedAt: null,
}, },
}), }),
// Not signed counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -95,12 +97,13 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
}, },
}, },
createdAt, createdAt,
deletedAt: null,
}, },
}), }),
// Has signed counts.
prisma.document.groupBy({ prisma.document.groupBy({
by: ['status'], by: ['status'],
_count: { _count: {
@ -120,9 +123,9 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null,
}, },
{ {
status: ExtendedDocumentStatus.COMPLETED, status: ExtendedDocumentStatus.COMPLETED,
@ -130,6 +133,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
}, },
@ -198,6 +202,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,
@ -219,6 +224,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,
@ -229,6 +235,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
some: { some: {
email: teamEmail, email: teamEmail,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
documentDeletedAt: null,
}, },
}, },
deletedAt: null, deletedAt: null,

View File

@ -88,6 +88,11 @@ export const resendDocument = async ({
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
const { email, name } = recipient; const { email, name } = recipient;
const selfSigner = email === user.email;
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
recipient.role
].actionVerb.toLowerCase()} it.`;
const customEmailTemplate = { const customEmailTemplate = {
'signer.name': name, 'signer.name': name,
@ -104,12 +109,20 @@ export const resendDocument = async ({
inviterEmail: user.email, inviterEmail: user.email,
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), customBody: renderCustomEmailTemplate(
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role, role: recipient.role,
selfSigner,
}); });
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
const emailSubject = selfSigner
? `Reminder: Please ${actionVerb.toLowerCase()} your document`
: `Reminder: Please ${actionVerb.toLowerCase()} this document`;
await prisma.$transaction( await prisma.$transaction(
async (tx) => { async (tx) => {
await mailer.sendMail({ await mailer.sendMail({
@ -122,8 +135,8 @@ export const resendDocument = async ({
address: FROM_ADDRESS, address: FROM_ADDRESS,
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
: `Please ${actionVerb.toLowerCase()} this document`, : emailSubject,
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
}); });

View File

@ -14,7 +14,8 @@ import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file'; import { putPdfFile } from '../../universal/upload/put-file';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations'; import { flattenAnnotations } from '../pdf/flatten-annotations';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf'; import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances'; import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
@ -39,6 +40,11 @@ export const sealDocument = async ({
const document = await prisma.document.findFirstOrThrow({ const document = await prisma.document.findFirstOrThrow({
where: { where: {
id: documentId, id: documentId,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
}, },
include: { include: {
documentData: true, documentData: true,
@ -52,10 +58,6 @@ export const sealDocument = async ({
throw new Error(`Document ${document.id} has no document data`); throw new Error(`Document ${document.id} has no document data`);
} }
if (document.status !== DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has not been completed`);
}
const recipients = await prisma.recipient.findMany({ const recipients = await prisma.recipient.findMany({
where: { where: {
documentId: document.id, documentId: document.id,
@ -91,6 +93,10 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy // !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData); const pdfData = await getFile(documentData);
const certificate = await getCertificatePdf({ documentId })
.then(async (doc) => PDFDocument.load(doc))
.catch(() => null);
const doc = await PDFDocument.load(pdfData); const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature // Normalize and flatten layers that could cause issues with the signature
@ -98,6 +104,14 @@ export const sealDocument = async ({
doc.getForm().flatten(); doc.getForm().flatten();
flattenAnnotations(doc); flattenAnnotations(doc);
if (certificate) {
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
certificatePages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) { for (const field of fields) {
await insertFieldInPDF(doc, field); await insertFieldInPDF(doc, field);
} }
@ -108,7 +122,7 @@ export const sealDocument = async ({
const { name, ext } = path.parse(document.title); const { name, ext } = path.parse(document.title);
const { data: newData } = await putFile({ const { data: newData } = await putPdfFile({
name: `${name}_signed${ext}`, name: `${name}_signed${ext}`,
type: 'application/pdf', type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer), arrayBuffer: async () => Promise.resolve(pdfBuffer),
@ -127,6 +141,16 @@ export const sealDocument = async ({
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.document.update({
where: {
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentData.update({ await tx.documentData.update({
where: { where: {
id: documentData.id, id: documentData.id,

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