Compare commits

..

34 Commits

Author SHA1 Message Date
David Nguyen
9067351f96 fix: demo 2024-04-27 17:19:58 +07:00
David Nguyen
364c499927 fix: increase trpc max duration 2024-04-27 15:21:46 +07:00
David Nguyen
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
Lucas Smith
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
Mythie
481d739c37 chore: update package-lock 2024-04-26 13:25:16 +10:00
Mythie
88dedc9829 fix: use cdp and upgrade playwright again 2024-04-26 13:18:31 +10:00
Lucas Smith
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
David Nguyen
e1573465f6 fix: hide team webhooks from users 2024-04-25 23:32:59 +07:00
Lucas Smith
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
Lucas Smith
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
Lucas Smith
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
David Nguyen
4de122f814 fix: hide account action reauth 2024-04-24 20:07:38 +07:00
David Nguyen
e4cf9c8251 fix: add server logic 2024-04-24 19:51:18 +07:00
David Nguyen
41ed6c9ad7 fix: disable cert download when document not complete 2024-04-24 19:49:10 +07:00
Mythie
713cd09a06 fix: downgrade playwright 2024-04-24 19:07:18 +10:00
Mythie
87423e240a chore: update foreign key constraints 2024-04-24 17:32:11 +10:00
Adithya Krishna
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
David Nguyen
afaeba9739 fix: resize fields 2024-04-22 13:31:49 +07:00
Catalin Pit
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
David Nguyen
f6e6dac46c fix: update migration to drop invalid fields 2024-04-19 17:58:32 +07:00
David Nguyen
a97ffa97a4 Merge branch 'main' into feat/visible-fields 2024-04-19 17:54:32 +07:00
Catalin Pit
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
David Nguyen
bd40e63392 fix: update document deletion logic (#1100) 2024-04-19 17:37:38 +07:00
David Nguyen
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
David Nguyen
6526377f1b feat: add visible completed fields 2024-04-18 21:56:31 +07:00
Adithya Krishna
f8ddb0f922 chore: update filename for bulk recipients 2024-04-18 18:12:08 +05:30
Adithya Krishna
96e4797cdd Merge branch 'main' into chore/pdf-extension 2024-04-18 18:02:58 +05:30
Catalin Pit
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
David Nguyen
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
Timur Ercan
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
Adithya Krishna
3bc9b5ada0 chore: fix text padding 2024-04-17 04:08:41 +05:30
Adithya Krishna
1126fe4bff chore: added filename extension check 2024-04-17 03:52:59 +05:30
Deep Golani
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
Catalin Pit
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
63 changed files with 1530 additions and 522 deletions

View File

@@ -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

@@ -19,7 +19,7 @@ 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 {
@@ -41,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) => {
@@ -59,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);
@@ -127,7 +128,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
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>
@@ -154,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

@@ -133,7 +133,11 @@ 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">
<DownloadCertificateButton className="mr-2" documentId={document.id} /> <DownloadCertificateButton
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<DownloadAuditLogButton documentId={document.id} /> <DownloadAuditLogButton documentId={document.id} />
</div> </div>

View File

@@ -2,6 +2,7 @@
import { DownloadIcon } from 'lucide-react'; import { DownloadIcon } from 'lucide-react';
import { DocumentStatus } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
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';
@@ -10,11 +11,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadCertificateButtonProps = { export type DownloadCertificateButtonProps = {
className?: string; className?: string;
documentId: number; documentId: number;
documentStatus: DocumentStatus;
}; };
export const DownloadCertificateButton = ({ export const DownloadCertificateButton = ({
className, className,
documentId, documentId,
documentStatus,
}: DownloadCertificateButtonProps) => { }: DownloadCertificateButtonProps) => {
const { toast } = useToast(); const { toast } = useToast();
@@ -69,6 +72,7 @@ export const DownloadCertificateButton = ({
className={cn('w-full sm:w-auto', className)} className={cn('w-full sm:w-auto', className)}
loading={isLoading} loading={isLoading}
variant="outline" variant="outline"
disabled={documentStatus !== DocumentStatus.COMPLETED}
onClick={() => void onDownloadCertificatesClick()} onClick={() => void onDownloadCertificatesClick()}
> >
{!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />} {!isLoading && <DownloadIcon className="mr-1.5 h-4 w-4" />}

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

@@ -18,7 +18,10 @@ 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>();
@@ -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

@@ -131,7 +131,7 @@ export default async function CompletedSigningPage({
</div> </div>
)) ))
.with({ deletedAt: null }, () => ( .with({ deletedAt: null }, () => (
<div className="flex items-center text-center text-blue-600"> <div className="flex items-center mt-4 text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" /> <Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span> <span className="text-sm">Waiting for others to sign</span>
</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

@@ -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

@@ -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

@@ -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

@@ -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: 90,
api: { api: {
bodyParser: { bodyParser: {
sizeLimit: '50mb', sizeLimit: '50mb',

28
package-lock.json generated
View File

@@ -22,7 +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", "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"
@@ -4716,12 +4716,12 @@
} }
}, },
"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"
@@ -4745,12 +4745,12 @@
} }
}, },
"node_modules/@playwright/test/node_modules/playwright": { "node_modules/@playwright/test/node_modules/playwright": {
"version": "1.40.0", "version": "1.43.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz",
"integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"playwright-core": "1.40.0" "playwright-core": "1.43.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -4763,9 +4763,9 @@
} }
}, },
"node_modules/@playwright/test/node_modules/playwright-core": { "node_modules/@playwright/test/node_modules/playwright-core": {
"version": "1.40.0", "version": "1.43.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz",
"integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==",
"dev": true, "dev": true,
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
@@ -24981,7 +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", "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",
@@ -24989,7 +24989,7 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@playwright/browser-chromium": "^1.43.0", "@playwright/browser-chromium": "1.43.0",
"@types/luxon": "^3.3.1" "@types/luxon": "^3.3.1"
} }
}, },

View File

@@ -38,7 +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", "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"

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

@@ -23,7 +23,10 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/
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 { putFile } 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;

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();

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

@@ -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

@@ -39,7 +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", "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",
@@ -48,6 +48,6 @@
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^3.3.1", "@types/luxon": "^3.3.1",
"@playwright/browser-chromium": "^1.43.0" "@playwright/browser-chromium": "1.43.0"
} }
} }

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) {
@@ -74,6 +74,13 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`); throw new Error(`Recipient ${recipient.id} has unsigned fields`);
} }
// eslint-disable-next-line no-promise-executor-return
await new Promise<void>((resolve) => setTimeout(() => resolve(), 70000));
if (Date.now() > 0) {
throw new Error('Test');
}
// Document reauth for completing documents is currently not required. // Document reauth for completing documents is currently not required.
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({ // const { derivedRecipientActionAuth } = extractDocumentAuthMethods({

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,178 @@ 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.
if (userRecipient?.documentDeletedAt === null) {
await prisma.recipient.update({
where: {
documentId_email: {
documentId: document.id,
email: user.email,
},
},
data: {
documentDeletedAt: new Date().toISOString(),
},
});
}
// 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

@@ -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 ? 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({
@@ -123,7 +136,7 @@ export const resendDocument = async ({
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(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

@@ -80,7 +80,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
attachments: [ attachments: [
{ {
filename: document.title, filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
content: Buffer.from(completedDocument), content: Buffer.from(completedDocument),
}, },
], ],
@@ -130,7 +130,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
attachments: [ attachments: [
{ {
filename: document.title, filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
content: Buffer.from(completedDocument), content: Buffer.from(completedDocument),
}, },
], ],

View File

@@ -127,6 +127,11 @@ export const sendDocument = 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,
@@ -143,12 +148,20 @@ export const sendDocument = async ({
inviterEmail: user.email, inviterEmail: user.email,
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), customBody: renderCustomEmailTemplate(
selfSigner ? 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
? `Please ${actionVerb.toLowerCase()} your document`
: `Please ${actionVerb.toLowerCase()} this document`;
await prisma.$transaction( await prisma.$transaction(
async (tx) => { async (tx) => {
await mailer.sendMail({ await mailer.sendMail({
@@ -162,7 +175,7 @@ export const sendDocument = async ({
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(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

@@ -0,0 +1,29 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type GetCompletedFieldsForDocumentOptions = {
documentId: number;
};
export const getCompletedFieldsForDocument = async ({
documentId,
}: GetCompletedFieldsForDocumentOptions) => {
return await prisma.field.findMany({
where: {
documentId,
Recipient: {
signingStatus: SigningStatus.SIGNED,
},
inserted: true,
},
include: {
Signature: true,
Recipient: {
select: {
name: true,
email: true,
},
},
},
});
};

View File

@@ -0,0 +1,33 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type GetCompletedFieldsForTokenOptions = {
token: string;
};
export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsForTokenOptions) => {
return await prisma.field.findMany({
where: {
Document: {
Recipient: {
some: {
token,
},
},
},
Recipient: {
signingStatus: SigningStatus.SIGNED,
},
inserted: true,
},
include: {
Signature: true,
Recipient: {
select: {
name: true,
email: true,
},
},
},
});
};

View File

@@ -36,8 +36,8 @@ export const removeSignedFieldWithToken = async ({
throw new Error(`Document not found for field ${field.id}`); throw new Error(`Document not found for field ${field.id}`);
} }
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 (recipient?.signingStatus === SigningStatus.SIGNED) { if (recipient?.signingStatus === SigningStatus.SIGNED) {

View File

@@ -58,14 +58,14 @@ export const signFieldWithToken = async ({
throw new Error(`Recipient not found for field ${field.id}`); throw new Error(`Recipient not found for field ${field.id}`);
} }
if (document.status === DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has already been completed`);
}
if (document.deletedAt) { if (document.deletedAt) {
throw new Error(`Document ${document.id} has been deleted`); throw new Error(`Document ${document.id} has been deleted`);
} }
if (document.status !== DocumentStatus.PENDING) {
throw new Error(`Document ${document.id} must be pending for signing`);
}
if (recipient?.signingStatus === SigningStatus.SIGNED) { if (recipient?.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`); throw new Error(`Recipient ${recipient.id} has already signed`);
} }

View File

@@ -18,7 +18,9 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
let browser: Browser; let browser: Browser;
if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) { if (process.env.NEXT_PRIVATE_BROWSERLESS_URL) {
browser = await chromium.connect(process.env.NEXT_PRIVATE_BROWSERLESS_URL); // !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(process.env.NEXT_PRIVATE_BROWSERLESS_URL);
} else { } else {
browser = await chromium.launch(); browser = await chromium.launch();
} }

View File

@@ -89,6 +89,10 @@ export const createDocumentFromTemplate = async ({
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email); const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
if (!documentRecipient) {
throw new Error('Recipient not found.');
}
return { return {
type: field.type, type: field.type,
page: field.page, page: field.page,
@@ -99,7 +103,7 @@ export const createDocumentFromTemplate = async ({
customText: field.customText, customText: field.customText,
inserted: field.inserted, inserted: field.inserted,
documentId: document.id, documentId: document.id,
recipientId: documentRecipient?.id || null, recipientId: documentRecipient.id,
}; };
}), }),
}); });

View File

@@ -81,6 +81,10 @@ export const duplicateTemplate = async ({
(doc) => doc.email === recipient?.email, (doc) => doc.email === recipient?.email,
); );
if (!duplicatedTemplateRecipient) {
throw new Error('Recipient not found.');
}
return { return {
type: field.type, type: field.type,
page: field.page, page: field.page,
@@ -91,7 +95,7 @@ export const duplicateTemplate = async ({
customText: field.customText, customText: field.customText,
inserted: field.inserted, inserted: field.inserted,
templateId: duplicatedTemplate.id, templateId: duplicatedTemplate.id,
recipientId: duplicatedTemplateRecipient?.id || null, recipientId: duplicatedTemplateRecipient.id,
}; };
}), }),
}); });

View File

@@ -4,6 +4,7 @@ export const getWebhooksByUserId = async (userId: number) => {
return await prisma.webhook.findMany({ return await prisma.webhook.findMany({
where: { where: {
userId, userId,
teamId: null,
}, },
orderBy: { orderBy: {
createdAt: 'desc', createdAt: 'desc',

View File

@@ -0,0 +1,3 @@
import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token';
export type CompletedField = Awaited<ReturnType<typeof getCompletedFieldsForToken>>[number];

View File

@@ -17,6 +17,7 @@ export const getFlag = async (
options?: GetFlagOptions, options?: GetFlagOptions,
): Promise<TFeatureFlagValue> => { ): Promise<TFeatureFlagValue> => {
const requestHeaders = options?.requestHeaders ?? {}; const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) { if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true; return LOCAL_FEATURE_FLAGS[flag] ?? true;
@@ -25,7 +26,7 @@ export const getFlag = async (
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`); const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
url.searchParams.set('flag', flag); url.searchParams.set('flag', flag);
const response = await fetch(url, { return await fetch(url, {
headers: { headers: {
...requestHeaders, ...requestHeaders,
}, },
@@ -35,9 +36,10 @@ export const getFlag = async (
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res)) .then((res) => ZFeatureFlagValueSchema.parse(res))
.catch(() => false); .catch((err) => {
console.error(err);
return response; return LOCAL_FEATURE_FLAGS[flag] ?? false;
});
}; };
/** /**
@@ -50,6 +52,7 @@ export const getAllFlags = async (
options?: GetFlagOptions, options?: GetFlagOptions,
): Promise<Record<string, TFeatureFlagValue>> => { ): Promise<Record<string, TFeatureFlagValue>> => {
const requestHeaders = options?.requestHeaders ?? {}; const requestHeaders = options?.requestHeaders ?? {};
delete requestHeaders['content-length'];
if (!isFeatureFlagEnabled()) { if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS; return LOCAL_FEATURE_FLAGS;
@@ -67,7 +70,10 @@ export const getAllFlags = async (
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res)) .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS); .catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
}; };
/** /**
@@ -89,7 +95,10 @@ export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFla
}) })
.then(async (res) => res.json()) .then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res)) .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS); .catch((err) => {
console.error(err);
return LOCAL_FEATURE_FLAGS;
});
}; };
interface GetFlagOptions { interface GetFlagOptions {

View File

@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "documentDeletedAt" TIMESTAMP(3);
-- Hard delete all PENDING documents that have been soft deleted
DELETE FROM "Document" WHERE "deletedAt" IS NOT NULL AND "status" = 'PENDING';
-- Update all recipients who are the owner of the document and where the document has deletedAt set to not null
UPDATE "Recipient"
SET "documentDeletedAt" = "Document"."deletedAt"
FROM "Document", "User"
WHERE "Recipient"."documentId" = "Document"."id"
AND "Recipient"."email" = "User"."email"
AND "Document"."deletedAt" IS NOT NULL;

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- Made the column `recipientId` on table `Field` required. This step will fail if there are existing NULL values in that column.
*/
-- Drop all Fields where the recipientId is null
DELETE FROM "Field" WHERE "recipientId" IS NULL;
-- AlterTable
ALTER TABLE "Field" ALTER COLUMN "recipientId" SET NOT NULL;

View File

@@ -0,0 +1,23 @@
-- DropForeignKey
ALTER TABLE "PasswordResetToken" DROP CONSTRAINT "PasswordResetToken_userId_fkey";
-- DropForeignKey
ALTER TABLE "Signature" DROP CONSTRAINT "Signature_fieldId_fkey";
-- DropForeignKey
ALTER TABLE "Team" DROP CONSTRAINT "Team_ownerUserId_fkey";
-- DropForeignKey
ALTER TABLE "TeamMember" DROP CONSTRAINT "TeamMember_userId_fkey";
-- AddForeignKey
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Signature" ADD CONSTRAINT "Signature_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "Field"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -98,7 +98,7 @@ model PasswordResetToken {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
expiry DateTime expiry DateTime
userId Int userId Int
User User @relation(fields: [userId], references: [id]) User User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model Passkey { model Passkey {
@@ -347,23 +347,24 @@ enum RecipientRole {
} }
model Recipient { model Recipient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
documentId Int? documentId Int?
templateId Int? templateId Int?
email String @db.VarChar(255) email String @db.VarChar(255)
name String @default("") @db.VarChar(255) name String @default("") @db.VarChar(255)
token String token String
expired DateTime? documentDeletedAt DateTime?
signedAt DateTime? expired DateTime?
authOptions Json? signedAt DateTime?
role RecipientRole @default(SIGNER) authOptions Json?
readStatus ReadStatus @default(NOT_OPENED) role RecipientRole @default(SIGNER)
signingStatus SigningStatus @default(NOT_SIGNED) readStatus ReadStatus @default(NOT_OPENED)
sendStatus SendStatus @default(NOT_SENT) signingStatus SigningStatus @default(NOT_SIGNED)
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) sendStatus SendStatus @default(NOT_SENT)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Field Field[] Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Signature Signature[] Field Field[]
Signature Signature[]
@@unique([documentId, email]) @@unique([documentId, email])
@@unique([templateId, email]) @@unique([templateId, email])
@@ -386,7 +387,7 @@ model Field {
secondaryId String @unique @default(cuid()) secondaryId String @unique @default(cuid())
documentId Int? documentId Int?
templateId Int? templateId Int?
recipientId Int? recipientId Int
type FieldType type FieldType
page Int page Int
positionX Decimal @default(0) positionX Decimal @default(0)
@@ -397,7 +398,7 @@ model Field {
inserted Boolean inserted Boolean
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade) Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Signature Signature? Signature Signature?
@@index([documentId]) @@index([documentId])
@@ -414,7 +415,7 @@ model Signature {
typedSignature String? typedSignature String?
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade) Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict) Field Field @relation(fields: [fieldId], references: [id], onDelete: Cascade)
@@index([recipientId]) @@index([recipientId])
} }
@@ -456,7 +457,7 @@ model Team {
emailVerification TeamEmailVerification? emailVerification TeamEmailVerification?
transferVerification TeamTransferVerification? transferVerification TeamTransferVerification?
owner User @relation(fields: [ownerUserId], references: [id]) owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
subscription Subscription? subscription Subscription?
document Document[] document Document[]
@@ -482,7 +483,7 @@ model TeamMember {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
role TeamMemberRole role TeamMemberRole
userId Int userId Int
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([userId, teamId]) @@unique([userId, teamId])
@@ -563,5 +564,5 @@ model SiteSettings {
data Json data Json
lastModifiedByUserId Int? lastModifiedByUserId Int?
lastModifiedAt DateTime @default(now()) lastModifiedAt DateTime @default(now())
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id]) lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id], onDelete: SetNull)
} }

View File

@@ -342,14 +342,15 @@ export const seedPendingDocumentWithFullFields = async ({
}, },
}); });
const latestDocument = updateDocumentOptions const latestDocument = await prisma.document.update({
? await prisma.document.update({ where: {
where: { id: document.id,
id: document.id, },
}, data: {
data: updateDocumentOptions, ...updateDocumentOptions,
}) status: DocumentStatus.PENDING,
: document; },
});
return { return {
document: latestDocument, document: latestDocument,

View File

@@ -4,6 +4,7 @@ import { DateTime } from 'luxon';
import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError } from '@documenso/lib/errors/app-error';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { createDocument } from '@documenso/lib/server-only/document/create-document';
@@ -20,6 +21,7 @@ import { updateDocumentSettings } from '@documenso/lib/server-only/document/upda
import { updateTitle } from '@documenso/lib/server-only/document/update-title'; import { updateTitle } from '@documenso/lib/server-only/document/update-title';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus } from '@documenso/prisma/client';
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
@@ -413,6 +415,10 @@ export const documentRouter = router({
teamId, teamId,
}); });
if (document.status !== DocumentStatus.COMPLETED) {
throw new AppError('DOCUMENT_NOT_COMPLETE');
}
const encrypted = encryptSecondaryData({ const encrypted = encryptSecondaryData({
data: document.id.toString(), data: document.id.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),

View File

@@ -19,6 +19,7 @@ export type FieldContainerPortalProps = {
field: Field; field: Field;
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
cardClassName?: string;
}; };
export function FieldContainerPortal({ export function FieldContainerPortal({
@@ -44,7 +45,7 @@ export function FieldContainerPortal({
); );
} }
export function FieldRootContainer({ field, children }: FieldContainerPortalProps) { export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false); const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null); const ref = React.useRef<HTMLDivElement>(null);
@@ -78,6 +79,7 @@ export function FieldRootContainer({ field, children }: FieldContainerPortalProp
{ {
'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating, 'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating,
}, },
cardClassName,
)} )}
ref={ref} ref={ref}
data-inserted={field.inserted ? 'true' : 'false'} data-inserted={field.inserted ? 'true' : 'false'}

View File

@@ -9,7 +9,11 @@ import { useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { DocumentAccessAuth, DocumentActionAuth } from '@documenso/lib/types/document-auth'; import {
DocumentAccessAuth,
DocumentActionAuth,
DocumentAuth,
} from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client'; import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
@@ -216,9 +220,9 @@ export const AddSettingsFormPartial = ({
</p> </p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2"> <ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li> {/* <li>
<strong>Require account</strong> - The recipient must be signed in <strong>Require account</strong> - The recipient must be signed in
</li> </li> */}
<li> <li>
<strong>Require passkey</strong> - The recipient must have an account <strong>Require passkey</strong> - The recipient must have an account
and passkey configured via their settings and passkey configured via their settings
@@ -242,11 +246,13 @@ export const AddSettingsFormPartial = ({
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper"> <SelectContent position="popper">
{Object.values(DocumentActionAuth).map((authType) => ( {Object.values(DocumentActionAuth)
<SelectItem key={authType} value={authType}> .filter((auth) => auth !== DocumentAuth.ACCOUNT)
{DOCUMENT_AUTH_TYPES[authType].value} .map((authType) => (
</SelectItem> <SelectItem key={authType} value={authType}>
))} {DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
{/* Note: -1 is remapped in the Zod schema to the required value. */} {/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value={'-1'}>None</SelectItem> <SelectItem value={'-1'}>None</SelectItem>

View File

@@ -139,10 +139,8 @@ export const AddSignersFormPartial = ({
}; };
const onAddSelfSigner = () => { const onAddSelfSigner = () => {
const newSelfSignerId = nanoid(12);
appendSigner({ appendSigner({
formId: newSelfSignerId, formId: nanoid(12),
name: user?.name ?? '', name: user?.name ?? '',
email: user?.email ?? '', email: user?.email ?? '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
@@ -249,9 +247,7 @@ export const AddSignersFormPartial = ({
'col-span-4': showAdvancedSettings, 'col-span-4': showAdvancedSettings,
})} })}
> >
{!showAdvancedSettings && index === 0 && ( {!showAdvancedSettings && index === 0 && <FormLabel>Name</FormLabel>}
<FormLabel required>Name</FormLabel>
)}
<FormControl> <FormControl>
<Input <Input
@@ -306,10 +302,10 @@ export const AddSignersFormPartial = ({
global action signing authentication method configured in global action signing authentication method configured in
the "General Settings" step the "General Settings" step
</li> </li>
<li> {/* <li>
<strong>Require account</strong> - The recipient must be <strong>Require account</strong> - The recipient must be
signed in signed in
</li> </li> */}
<li> <li>
<strong>Require passkey</strong> - The recipient must have <strong>Require passkey</strong> - The recipient must have
an account and passkey configured via their settings an account and passkey configured via their settings
@@ -330,11 +326,13 @@ export const AddSignersFormPartial = ({
{/* Note: -1 is remapped in the Zod schema to the required value. */} {/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">Inherit authentication method</SelectItem> <SelectItem value="-1">Inherit authentication method</SelectItem>
{Object.values(RecipientActionAuth).map((authType) => ( {Object.values(RecipientActionAuth)
<SelectItem key={authType} value={authType}> .filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
{DOCUMENT_AUTH_TYPES[authType].value} .map((authType) => (
</SelectItem> <SelectItem key={authType} value={authType}>
))} {DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
@@ -363,29 +361,83 @@ export const AddSignersFormPartial = ({
<SelectContent align="end"> <SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}> <SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center"> <div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span> <div className="flex w-[150px] items-center">
Signer <span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
</div> Needs to sign
</SelectItem> </div>
<Tooltip>
<SelectItem value={RecipientRole.CC}> <TooltipTrigger>
<div className="flex items-center"> <InfoIcon className="h-4 w-4" />
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span> </TooltipTrigger>
Receives copy <TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to sign the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value={RecipientRole.APPROVER}> <SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center"> <div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span> <div className="flex w-[150px] items-center">
Approver <span className="mr-2">
{ROLE_ICONS[RecipientRole.APPROVER]}
</span>
Needs to approve
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to approve the document for it to
be completed.
</p>
</TooltipContent>
</Tooltip>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value={RecipientRole.VIEWER}> <SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center"> <div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span> <div className="flex w-[150px] items-center">
Viewer <span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Needs to view
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to view the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is not required to take any action and
receives a copy of the document after it is completed.
</p>
</TooltipContent>
</Tooltip>
</div> </div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>

View File

@@ -30,4 +30,66 @@ const PopoverContent = React.forwardRef<
PopoverContent.displayName = PopoverPrimitive.Content.displayName; PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent }; type PopoverHoverProps = {
trigger: React.ReactNode;
children: React.ReactNode;
contentProps?: React.ComponentPropsWithoutRef<typeof PopoverContent>;
};
const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) => {
const [open, setOpen] = React.useState(false);
const isControlled = React.useRef(false);
const isMouseOver = React.useRef<boolean>(false);
const onMouseEnter = () => {
isMouseOver.current = true;
if (isControlled.current) {
return;
}
setOpen(true);
};
const onMouseLeave = () => {
isMouseOver.current = false;
if (isControlled.current) {
return;
}
setTimeout(() => {
setOpen(isMouseOver.current);
}, 200);
};
const onOpenChange = (newOpen: boolean) => {
isControlled.current = newOpen;
setOpen(newOpen);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
className="flex cursor-pointer outline-none"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{trigger}
</PopoverTrigger>
<PopoverContent
side="top"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
{...contentProps}
>
{children}
</PopoverContent>
</Popover>
);
};
export { Popover, PopoverTrigger, PopoverContent, PopoverHover };

View File

@@ -4,7 +4,7 @@ import React, { useId, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react'; import { InfoIcon, Plus, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
@@ -25,6 +25,7 @@ import type { DocumentFlowStep } from '../document-flow/types';
import { ROLE_ICONS } from '../recipient-role-icons'; import { ROLE_ICONS } from '../recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select'; import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
@@ -90,10 +91,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
}); });
const onAddPlaceholderSelfRecipient = () => { const onAddPlaceholderSelfRecipient = () => {
const newSelfSignerId = nanoid(12);
appendSigner({ appendSigner({
formId: newSelfSignerId, formId: nanoid(12),
name: user?.name ?? '', name: user?.name ?? '',
email: user?.email ?? '', email: user?.email ?? '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
@@ -161,29 +160,81 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<SelectContent className="" align="end"> <SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}> <SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center"> <div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span> <div className="flex w-[150px] items-center">
Signer <span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
</div> Needs to sign
</SelectItem> </div>
<Tooltip>
<SelectItem value={RecipientRole.CC}> <TooltipTrigger>
<div className="flex items-center"> <InfoIcon className="h-4 w-4" />
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span> </TooltipTrigger>
Receives copy <TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to sign the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value={RecipientRole.APPROVER}> <SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center"> <div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span> <div className="flex w-[150px] items-center">
Approver <span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Needs to approve
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to approve the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value={RecipientRole.VIEWER}> <SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center"> <div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span> <div className="flex w-[150px] items-center">
Viewer <span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Needs to view
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is required to view the document for it to be
completed.
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
The recipient is not required to take any action and receives a
copy of the document after it is completed.
</p>
</TooltipContent>
</Tooltip>
</div> </div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>