From 80c758fb6283b7bf3183740033a25436694c82fa Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Fri, 12 Apr 2024 15:37:08 +0200 Subject: [PATCH 01/27] chore: audit log menu item label (#1102) --- .../(dashboard)/documents/[id]/document-page-view-dropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx index 7b6bb8a91..0fb592ea1 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -118,7 +118,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro - Logs + Audit Log From 0f87dc047b475a65b9c76cf2735dad8c406fde28 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Mon, 15 Apr 2024 10:27:46 +0300 Subject: [PATCH 02/27] fix: swagger documentation authentication (#1037) ## Summary by CodeRabbit - **Refactor** - Enhanced the API specification generation process to include operation IDs, security schemes, and security definitions more efficiently. --------- Co-authored-by: Lucas Smith --- packages/api/v1/openapi.ts | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/api/v1/openapi.ts b/packages/api/v1/openapi.ts index af0582195..55ec4d7fd 100644 --- a/packages/api/v1/openapi.ts +++ b/packages/api/v1/openapi.ts @@ -2,16 +2,34 @@ import { generateOpenApi } from '@ts-rest/open-api'; import { ApiContractV1 } from './contract'; -export const OpenAPIV1 = generateOpenApi( - ApiContractV1, - { - info: { - title: 'Documenso API', - version: '1.0.0', - description: 'The Documenso API for retrieving, creating, updating and deleting documents.', +export const OpenAPIV1 = Object.assign( + generateOpenApi( + ApiContractV1, + { + info: { + title: 'Documenso API', + version: '1.0.0', + description: 'The Documenso API for retrieving, creating, updating and deleting documents.', + }, }, - }, + { + setOperationId: true, + }, + ), { - setOperationId: true, + components: { + securitySchemes: { + authorization: { + type: 'apiKey', + in: 'header', + name: 'Authorization', + }, + }, + }, + security: [ + { + authorization: [], + }, + ], }, ); From c8a09099a372568ed1c2b0ac47acac3ceb2be243 Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Mon, 15 Apr 2024 10:29:56 +0300 Subject: [PATCH 03/27] fix: mask recipient token (#1051) The searchDocuments function is used for the shortcuts commands, afaik. The function returns the documents that match the user query (if any), alongside all their recipients. The reason for that is so it can build the path for the document. E.g. if you're the document owner, the document path will be `..../documents/{id}`. But if you're a signer for example, the document path (link) will be `..../sign/{token}`. So instead of doing that on the frontend, I moved it to the backend. At least that's what I understood. If I'm wrong, please correct me. ## Summary by CodeRabbit - **New Features** - Enhanced the `CommandMenu` component to simplify search result generation and improve document link management based on user roles. - **Refactor** - Updated document search logic to include recipient token masking and refined document mapping. - **Style** - Minor formatting improvement in document routing code. --- .../(dashboard)/common/command-menu.tsx | 20 +++-------------- .../document/search-documents-with-keyword.ts | 22 ++++++++++++------- .../trpc/server/document-router/router.ts | 1 + 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index bdc6c2064..812efd4b9 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Loader, Monitor, Moon, Sun } from 'lucide-react'; -import { useSession } from 'next-auth/react'; import { useTheme } from 'next-themes'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -18,7 +17,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META, } from '@documenso/lib/constants/trpc'; -import type { Document, Recipient } from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { CommandDialog, @@ -71,7 +69,6 @@ export type CommandMenuProps = { export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { const { setTheme } = useTheme(); - const { data: session } = useSession(); const router = useRouter(); @@ -93,17 +90,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }, ); - const isOwner = useCallback( - (document: Document) => document.userId === session?.user.id, - [session?.user.id], - ); - - const getSigningLink = useCallback( - (recipients: Recipient[]) => - `/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`, - [session?.user.email], - ); - const searchResults = useMemo(() => { if (!searchDocumentsData) { return []; @@ -111,10 +97,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { return searchDocumentsData.map((document) => ({ label: document.title, - path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient), - value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '), + path: document.path, + value: document.value, })); - }, [searchDocumentsData, isOwner, getSigningLink]); + }, [searchDocumentsData]); const currentPage = pages[pages.length - 1]; diff --git a/packages/lib/server-only/document/search-documents-with-keyword.ts b/packages/lib/server-only/document/search-documents-with-keyword.ts index 8125ae900..a9139f5d3 100644 --- a/packages/lib/server-only/document/search-documents-with-keyword.ts +++ b/packages/lib/server-only/document/search-documents-with-keyword.ts @@ -1,7 +1,6 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; - -import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; +import type { Document, Recipient, User } from '@documenso/prisma/client'; export type SearchDocumentsWithKeywordOptions = { query: string; @@ -79,12 +78,19 @@ export const searchDocumentsWithKeyword = async ({ take: limit, }); - const maskedDocuments = documents.map((document) => - maskRecipientTokensForDocument({ - document, - user, - }), - ); + const isOwner = (document: Document, user: User) => document.userId === user.id; + const getSigningLink = (recipients: Recipient[], user: User) => + `/sign/${recipients.find((r) => r.email === user.email)?.token}`; + + const maskedDocuments = documents.map((document) => { + const { Recipient, ...documentWithoutRecipient } = document; + + return { + ...documentWithoutRecipient, + path: isOwner(document, user) ? `/documents/${document.id}` : getSigningLink(Recipient, user), + value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '), + }; + }); return maskedDocuments; }; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 3cc61bef2..d12002674 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -358,6 +358,7 @@ export const documentRouter = router({ query, userId: ctx.user.id, }); + return documents; } catch (err) { console.error(err); From aa4b6f1723c43ff6461950598a203222c2c127aa Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Mon, 15 Apr 2024 14:22:34 +0530 Subject: [PATCH 04/27] feat: updated mobile header (#1004) **Description:** - Updated mobile header with respect to latest designs ## Summary by CodeRabbit - **New Features** - Added a new `showText` property to the `MenuSwitcher` component to control text visibility. - Added a `textSectionClassName` property to the `AvatarWithText` component for conditional text section styling. - Updated the `CommandDialog` and `DialogContent` components with new positioning and styling properties. - **Style Updates** - Adjusted text size responsiveness in the `Hero` component for various screen sizes. - Modified text truncation and input styling in the `Widget` component. - Changed the width of the `SheetContent` element in `MobileNavigation` and adjusted footer layout. - **Documentation** - Added instructions for certificate placement in `SIGNING.md`. - **Refactor** - Standardized type imports across various components and utilities for improved type checking. --------- Signed-off-by: Adithya Krishna Signed-off-by: Adithya Krishna Co-authored-by: David Nguyen --- SIGNING.md | 3 +- apps/marketing/src/api/claim-plan/fetcher.ts | 3 +- .../src/app/(marketing)/open/bar-metrics.tsx | 1 + .../app/(marketing)/open/funding-raised.tsx | 1 + .../src/app/(marketing)/open/metric-card.tsx | 2 +- .../src/app/(marketing)/open/salary-bands.tsx | 2 +- .../app/(marketing)/oss-friends/container.tsx | 5 +- apps/marketing/src/app/robots.ts | 2 +- apps/marketing/src/app/sitemap.ts | 2 +- .../src/components/(marketing)/hero.tsx | 2 +- .../(marketing)/open-build-template-bento.tsx | 2 +- .../src/components/(marketing)/widget.tsx | 4 +- .../components/form/form-error-message.tsx | 2 +- .../src/components/ui/background.tsx | 2 +- apps/marketing/src/providers/next-theme.tsx | 2 +- .../(dashboard)/layout/menu-switcher.tsx | 5 +- .../(dashboard)/layout/mobile-navigation.tsx | 4 +- packages/ui/primitives/avatar.tsx | 5 +- packages/ui/primitives/command.tsx | 6 ++- packages/ui/primitives/dialog.tsx | 49 +++++++++++-------- 20 files changed, 62 insertions(+), 42 deletions(-) diff --git a/SIGNING.md b/SIGNING.md index d1942ed8a..d8f664cee 100644 --- a/SIGNING.md +++ b/SIGNING.md @@ -17,7 +17,8 @@ For the digital signature of your documents you need a signing certificate in .p `openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt` 4. You will be prompted to enter a password for the p12 file. Choose a strong password and remember it, as you will need it to use the certificate (**can be empty for dev certificates**) -5. Place the certificate `/apps/web/resources/certificate.p12` + +5. Place the certificate `/apps/web/resources/certificate.p12` (If the path does not exist, it needs to be created) ## Docker diff --git a/apps/marketing/src/api/claim-plan/fetcher.ts b/apps/marketing/src/api/claim-plan/fetcher.ts index 0e533be5e..629ab7270 100644 --- a/apps/marketing/src/api/claim-plan/fetcher.ts +++ b/apps/marketing/src/api/claim-plan/fetcher.ts @@ -1,4 +1,5 @@ -import { TClaimPlanRequestSchema, ZClaimPlanResponseSchema } from './types'; +import type { TClaimPlanRequestSchema } from './types'; +import { ZClaimPlanResponseSchema } from './types'; export const claimPlan = async ({ name, diff --git a/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx b/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx index fb9c61f11..2d93b2e34 100644 --- a/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx +++ b/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx @@ -55,6 +55,7 @@ export const BarMetric = & { export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) => { const formattedData = data.map((item) => ({ amount: Number(item.amount), + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions date: formatMonth(item.date as string), })); diff --git a/apps/marketing/src/app/(marketing)/open/metric-card.tsx b/apps/marketing/src/app/(marketing)/open/metric-card.tsx index 6235f4f5e..f7bf59e62 100644 --- a/apps/marketing/src/app/(marketing)/open/metric-card.tsx +++ b/apps/marketing/src/app/(marketing)/open/metric-card.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import { cn } from '@documenso/ui/lib/utils'; diff --git a/apps/marketing/src/app/(marketing)/open/salary-bands.tsx b/apps/marketing/src/app/(marketing)/open/salary-bands.tsx index 31c254157..41754cff6 100644 --- a/apps/marketing/src/app/(marketing)/open/salary-bands.tsx +++ b/apps/marketing/src/app/(marketing)/open/salary-bands.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import { cn } from '@documenso/ui/lib/utils'; import { diff --git a/apps/marketing/src/app/(marketing)/oss-friends/container.tsx b/apps/marketing/src/app/(marketing)/oss-friends/container.tsx index 0f1f66664..f2ea4e855 100644 --- a/apps/marketing/src/app/(marketing)/oss-friends/container.tsx +++ b/apps/marketing/src/app/(marketing)/oss-friends/container.tsx @@ -2,13 +2,14 @@ import Link from 'next/link'; -import { Variants, motion } from 'framer-motion'; +import type { Variants } from 'framer-motion'; +import { motion } from 'framer-motion'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card'; -import { TOSSFriendsSchema } from './schema'; +import type { TOSSFriendsSchema } from './schema'; const ContainerVariants: Variants = { initial: { diff --git a/apps/marketing/src/app/robots.ts b/apps/marketing/src/app/robots.ts index cc718ff25..a222a892e 100644 --- a/apps/marketing/src/app/robots.ts +++ b/apps/marketing/src/app/robots.ts @@ -1,4 +1,4 @@ -import { MetadataRoute } from 'next'; +import type { MetadataRoute } from 'next'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; diff --git a/apps/marketing/src/app/sitemap.ts b/apps/marketing/src/app/sitemap.ts index b9becde3b..4913402f9 100644 --- a/apps/marketing/src/app/sitemap.ts +++ b/apps/marketing/src/app/sitemap.ts @@ -1,4 +1,4 @@ -import { MetadataRoute } from 'next'; +import type { MetadataRoute } from 'next'; import { allBlogPosts, allGenericPages } from 'contentlayer/generated'; diff --git a/apps/marketing/src/components/(marketing)/hero.tsx b/apps/marketing/src/components/(marketing)/hero.tsx index f416cc4ca..5809bd695 100644 --- a/apps/marketing/src/components/(marketing)/hero.tsx +++ b/apps/marketing/src/components/(marketing)/hero.tsx @@ -96,7 +96,7 @@ export const Hero = ({ className, ...props }: HeroProps) => { variants={HeroTitleVariants} initial="initial" animate="animate" - className="text-center text-4xl font-bold leading-tight tracking-tight lg:text-[64px]" + className="text-center text-4xl font-bold leading-tight tracking-tight md:text-[48px] lg:text-[64px]" > Document signing, finally open source. diff --git a/apps/marketing/src/components/(marketing)/open-build-template-bento.tsx b/apps/marketing/src/components/(marketing)/open-build-template-bento.tsx index 3c76c3547..4d4d6ad8a 100644 --- a/apps/marketing/src/components/(marketing)/open-build-template-bento.tsx +++ b/apps/marketing/src/components/(marketing)/open-build-template-bento.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import Image from 'next/image'; diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index 8b6c3cd8e..c4611746a 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -346,7 +346,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { {signatureText && (

{signatureText} @@ -360,7 +360,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { > , 'viewBox'>; diff --git a/apps/marketing/src/providers/next-theme.tsx b/apps/marketing/src/providers/next-theme.tsx index 6e9122e5a..d15114606 100644 --- a/apps/marketing/src/providers/next-theme.tsx +++ b/apps/marketing/src/providers/next-theme.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ThemeProvider as NextThemesProvider } from 'next-themes'; -import { ThemeProviderProps } from 'next-themes/dist/types'; +import type { ThemeProviderProps } from 'next-themes/dist/types'; export function ThemeProvider({ children, ...props }: ThemeProviderProps) { return {children}; diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx index 95f959ab2..bb8429adc 100644 --- a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx +++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx @@ -93,7 +93,7 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp diff --git a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx index a6009e7b5..ff5428298 100644 --- a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx +++ b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx @@ -46,7 +46,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat return ( - +

- © {new Date().getFullYear()} Documenso, Inc. All rights reserved. + © {new Date().getFullYear()} Documenso, Inc.
All rights reserved.

diff --git a/packages/ui/primitives/avatar.tsx b/packages/ui/primitives/avatar.tsx index c80e3a658..aa2f522fe 100644 --- a/packages/ui/primitives/avatar.tsx +++ b/packages/ui/primitives/avatar.tsx @@ -55,6 +55,8 @@ type AvatarWithTextProps = { primaryText: React.ReactNode; secondaryText?: React.ReactNode; rightSideComponent?: React.ReactNode; + // Optional class to hide/show the text beside avatar + textSectionClassName?: string; }; const AvatarWithText = ({ @@ -64,6 +66,7 @@ const AvatarWithText = ({ primaryText, secondaryText, rightSideComponent, + textSectionClassName, }: AvatarWithTextProps) => (
{avatarFallback} -
+
{primaryText} {secondaryText}
diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index 89777d417..89084ac12 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -32,7 +32,11 @@ type CommandDialogProps = DialogProps & { const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) => { return ( - + & { position?: 'start' | 'end' | 'center'; hideClose?: boolean; + /* Below prop is to add additional classes to the overlay */ + overlayClassName?: string; } ->(({ className, children, position = 'start', hideClose = false, ...props }, ref) => ( - - - - {children} - {!hideClose && ( - - - Close - - )} - - -)); +>( + ( + { className, children, overlayClassName, position = 'start', hideClose = false, ...props }, + ref, + ) => ( + + + + {children} + {!hideClose && ( + + + Close + + )} + + + ), +); DialogContent.displayName = DialogPrimitive.Content.displayName; From 0eeccfd64356389e448091612f1bec4a2cf0dc4e Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Tue, 16 Apr 2024 11:18:06 +0300 Subject: [PATCH 05/27] 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. ## 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. --- .../primitives/document-flow/add-signers.tsx | 77 ++++++++++++++----- .../add-template-placeholder-recipients.tsx | 33 +++++++- 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 7af4a06bc..27839a453 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -5,6 +5,7 @@ import React, { useId, useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { motion } from 'framer-motion'; import { InfoIcon, Plus, Trash } from 'lucide-react'; +import { useSession } from 'next-auth/react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; @@ -60,6 +61,8 @@ export const AddSignersFormPartial = ({ }: AddSignersFormProps) => { const { toast } = useToast(); const { remaining } = useLimits(); + const { data: session } = useSession(); + const user = session?.user; const initialId = useId(); @@ -135,6 +138,16 @@ export const AddSignersFormPartial = ({ ); }; + const onAddSelfSigner = () => { + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + actionAuth: undefined, + }); + }; + const onAddSigner = () => { appendSigner({ formId: nanoid(12), @@ -209,8 +222,12 @@ export const AddSignersFormPartial = ({ @@ -237,8 +254,12 @@ export const AddSignersFormPartial = ({ @@ -403,32 +424,46 @@ export const AddSignersFormPartial = ({ > - - {!alwaysShowAdvancedSettings && isDocumentEnterprise && ( -
- setShowAdvancedSettings(Boolean(value))} - /> - - -
- )} +
+ + {!alwaysShowAdvancedSettings && isDocumentEnterprise && ( +
+ setShowAdvancedSettings(Boolean(value))} + /> + + +
+ )} diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx index 87ec48ad1..08cfc4957 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -5,6 +5,7 @@ import React, { useId, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatePresence, motion } from 'framer-motion'; import { Plus, Trash } from 'lucide-react'; +import { useSession } from 'next-auth/react'; import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { nanoid } from '@documenso/lib/universal/id'; @@ -41,6 +42,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ onSubmit, }: AddTemplatePlaceholderRecipientsFormProps) => { const initialId = useId(); + const { data: session } = useSession(); + const user = session?.user; const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() => recipients.length > 1 ? recipients.length + 1 : 2, ); @@ -50,6 +53,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ const { control, handleSubmit, + getValues, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema), @@ -85,6 +89,15 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ name: 'signers', }); + const onAddPlaceholderSelfRecipient = () => { + appendSigner({ + formId: nanoid(12), + name: user?.name ?? '', + email: user?.email ?? '', + role: RecipientRole.SIGNER, + }); + }; + const onAddPlaceholderRecipient = () => { appendSigner({ formId: nanoid(12), @@ -203,11 +216,27 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ error={'signers__root' in errors && errors['signers__root']} /> -
- +
From db9899d29391f3cfaf7fdfd1b08b4d0dc86c1215 Mon Sep 17 00:00:00 2001 From: Deep Golani <54791570+deepgolani4@users.noreply.github.com> Date: Tue, 16 Apr 2024 18:12:28 +0530 Subject: [PATCH 06/27] 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. ## 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`. --------- Co-authored-by: David Nguyen --- .../components/(dashboard)/layout/desktop-nav.tsx | 15 +++++---------- .../src/components/(dashboard)/layout/header.tsx | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index 262e297d6..975ef7d0d 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -1,5 +1,3 @@ -'use client'; - import type { HTMLAttributes } 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 { Button } from '@documenso/ui/primitives/button'; -import { CommandMenu } from '../common/command-menu'; - const navigationLinks = [ { href: '/documents', @@ -25,13 +21,14 @@ const navigationLinks = [ }, ]; -export type DesktopNavProps = HTMLAttributes; +export type DesktopNavProps = HTMLAttributes & { + setIsCommandMenuOpen: (value: boolean) => void; +}; -export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { +export const DesktopNav = ({ className, setIsCommandMenuOpen, ...props }: DesktopNavProps) => { const pathname = usePathname(); const params = useParams(); - const [open, setOpen] = useState(false); const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); const rootHref = getRootHref(params, { returnEmptyRootString: true }); @@ -70,12 +67,10 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { ))}
- - -
- {uploadedFile ? ( - - - +
+
+
+
+
-
-
-
-
-
+

+ Uploaded Document +

-

- Uploaded Document -

- - - {uploadedFile.file.name} - - - - ) : ( - - )} -
+ + {uploadedFile.file.name} + + + + ) : ( + + )}
-
- + + + -
- - -
+ + + + ); From 3d3c53db023a5910792740bb52d575e79e841d9c Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Wed, 17 Apr 2024 15:36:54 +0300 Subject: [PATCH 10/27] 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 - [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. ## 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. --------- Co-authored-by: Timur Ercan --- .../primitives/document-flow/add-signers.tsx | 80 +++++++++++++++--- .../add-template-placeholder-recipients.tsx | 81 +++++++++++++++---- 2 files changed, 134 insertions(+), 27 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 27839a453..25169bcec 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -361,29 +361,83 @@ export const AddSignersFormPartial = ({
- {ROLE_ICONS[RecipientRole.SIGNER]} - Signer -
-
- - -
- {ROLE_ICONS[RecipientRole.CC]} - Receives copy +
+ {ROLE_ICONS[RecipientRole.SIGNER]} + Needs to sign +
+ + + + + +

+ The recipient is required to sign the document for it to be + completed. +

+
+
- {ROLE_ICONS[RecipientRole.APPROVER]} - Approver +
+ + {ROLE_ICONS[RecipientRole.APPROVER]} + + Needs to approve +
+ + + + + +

+ The recipient is required to approve the document for it to + be completed. +

+
+
- {ROLE_ICONS[RecipientRole.VIEWER]} - Viewer +
+ {ROLE_ICONS[RecipientRole.VIEWER]} + Needs to view +
+ + + + + +

+ The recipient is required to view the document for it to be + completed. +

+
+
+
+
+ + +
+
+ {ROLE_ICONS[RecipientRole.CC]} + Receives copy +
+ + + + + +

+ The recipient is not required to take any action and + receives a copy of the document after it is completed. +

+
+
diff --git a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx index 08cfc4957..e415f1aac 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -4,7 +4,7 @@ import React, { useId, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; 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 { 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 { Select, SelectContent, SelectItem, SelectTrigger } from '../select'; import { useStep } from '../stepper'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types'; @@ -159,29 +160,81 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
- {ROLE_ICONS[RecipientRole.SIGNER]} - Signer -
-
- - -
- {ROLE_ICONS[RecipientRole.CC]} - Receives copy +
+ {ROLE_ICONS[RecipientRole.SIGNER]} + Needs to sign +
+ + + + + +

+ The recipient is required to sign the document for it to be + completed. +

+
+
- {ROLE_ICONS[RecipientRole.APPROVER]} - Approver +
+ {ROLE_ICONS[RecipientRole.APPROVER]} + Needs to approve +
+ + + + + +

+ The recipient is required to approve the document for it to be + completed. +

+
+
- {ROLE_ICONS[RecipientRole.VIEWER]} - Viewer +
+ {ROLE_ICONS[RecipientRole.VIEWER]} + Needs to view +
+ + + + + +

+ The recipient is required to view the document for it to be + completed. +

+
+
+
+
+ + +
+
+ {ROLE_ICONS[RecipientRole.CC]} + Receives copy +
+ + + + + +

+ The recipient is not required to take any action and receives a + copy of the document after it is completed. +

+
+
From f8ddb0f9225e5c63a74377f71ab6e65d94ebbbe7 Mon Sep 17 00:00:00 2001 From: Adithya Krishna Date: Thu, 18 Apr 2024 18:12:08 +0530 Subject: [PATCH 11/27] chore: update filename for bulk recipients --- packages/lib/server-only/document/send-completed-email.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lib/server-only/document/send-completed-email.ts b/packages/lib/server-only/document/send-completed-email.ts index f5cef426c..f841aef33 100644 --- a/packages/lib/server-only/document/send-completed-email.ts +++ b/packages/lib/server-only/document/send-completed-email.ts @@ -130,7 +130,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo text: render(template, { plainText: true }), attachments: [ { - filename: document.title, + filename: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf', content: Buffer.from(completedDocument), }, ], From 6526377f1bd51d19b9e5677bd67c11cd612e5ddb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 18 Apr 2024 21:56:31 +0700 Subject: [PATCH 12/27] feat: add visible completed fields --- .../documents/[id]/document-page-view.tsx | 24 ++- .../src/app/(signing)/sign/[token]/page.tsx | 11 +- .../sign/[token]/signing-page-view.tsx | 12 +- .../avatar/stack-avatars-with-tooltip.tsx | 156 ++++++------------ .../document/document-read-only-fields.tsx | 113 +++++++++++++ .../get-completed-fields-for-document.ts | 29 ++++ .../field/get-completed-fields-for-token.ts | 33 ++++ .../template/create-document-from-template.ts | 6 +- .../template/duplicate-template.ts | 6 +- packages/lib/types/fields.ts | 3 + .../migration.sql | 8 + packages/prisma/schema.prisma | 4 +- packages/ui/components/field/field.tsx | 4 +- packages/ui/primitives/popover.tsx | 64 ++++++- 14 files changed, 356 insertions(+), 117 deletions(-) create mode 100644 apps/web/src/components/document/document-read-only-fields.tsx create mode 100644 packages/lib/server-only/field/get-completed-fields-for-document.ts create mode 100644 packages/lib/server-only/field/get-completed-fields-for-token.ts create mode 100644 packages/lib/types/fields.ts create mode 100644 packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index e20c88a27..3b89f63f5 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -8,6 +8,7 @@ import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { 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 { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; @@ -19,6 +20,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentHistorySheet } from '~/components/document/document-history-sheet'; +import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; import { DocumentStatus as DocumentStatusComponent, FRIENDLY_STATUS_MAP, @@ -83,11 +85,16 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) documentMeta.password = securePassword; } - const recipients = await getRecipientsForDocument({ - documentId, - teamId: team?.id, - userId: user.id, - }); + const [recipients, completedFields] = await Promise.all([ + getRecipientsForDocument({ + documentId, + teamId: team?.id, + userId: user.id, + }), + getCompletedFieldsForDocument({ + documentId, + }), + ]); const documentWithRecipients = { ...document, @@ -148,6 +155,13 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) + {document.status === DocumentStatus.PENDING && ( + + )} +
diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index e83f675ce..95c9b6512 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -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 { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; 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 { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; 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 [document, fields, recipient] = await Promise.all([ + const [document, fields, recipient, completedFields] = await Promise.all([ getDocumentAndSenderByToken({ token, userId: user?.id, @@ -45,6 +46,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp }).catch(() => null), getFieldsForToken({ token }), getRecipientByToken({ token }).catch(() => null), + getCompletedFieldsForToken({ token }), ]); if (!document || !document.documentData || !recipient) { @@ -120,7 +122,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp signature={user?.email === recipient.email ? user.signature : undefined} > - + ); diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx index c04679956..4691d0d4c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx @@ -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 { 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 { CompletedField } from '@documenso/lib/types/fields'; import type { Field, Recipient } from '@documenso/prisma/client'; import { FieldType, RecipientRole } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; import { truncateTitle } from '~/helpers/truncate-title'; import { DateField } from './date-field'; @@ -23,9 +25,15 @@ export type SigningPageViewProps = { document: DocumentAndSender; recipient: Recipient; 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 { documentData, documentMeta } = document; @@ -70,6 +78,8 @@ export const SigningPageView = ({ document, recipient, fields }: SigningPageView
+ + {fields.map((field) => match(field.type) diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 10f7d1e6a..b6a13b911 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -1,12 +1,10 @@ 'use client'; -import { useRef, useState } from 'react'; - import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { 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 { StackAvatar } from './stack-avatar'; @@ -23,11 +21,6 @@ export const StackAvatarsWithTooltip = ({ position, children, }: StackAvatarsWithTooltipProps) => { - const [open, setOpen] = useState(false); - - const isControlled = useRef(false); - const isMouseOverTimeout = useRef(null); - const waitingRecipients = recipients.filter( (recipient) => getRecipientType(recipient) === 'waiting', ); @@ -44,105 +37,62 @@ export const StackAvatarsWithTooltip = ({ (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 ( - - - {children || } - - - - {completedRecipients.length > 0 && ( -
-

Completed

- {completedRecipients.map((recipient: Recipient) => ( -
- -
-

{recipient.email}

-

- {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} -

-
+ } + contentProps={{ + className: 'flex flex-col gap-y-5 py-2', + side: position, + }} + > + {completedRecipients.length > 0 && ( +
+

Completed

+ {completedRecipients.map((recipient: Recipient) => ( +
+ +
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

- ))} -
- )} +
+ ))} +
+ )} - {waitingRecipients.length > 0 && ( -
-

Waiting

- {waitingRecipients.map((recipient: Recipient) => ( - - ))} -
- )} + {waitingRecipients.length > 0 && ( +
+

Waiting

+ {waitingRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} - {openedRecipients.length > 0 && ( -
-

Opened

- {openedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} + {openedRecipients.length > 0 && ( +
+

Opened

+ {openedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} - {uncompletedRecipients.length > 0 && ( -
-

Uncompleted

- {uncompletedRecipients.map((recipient: Recipient) => ( - - ))} -
- )} - - + {uncompletedRecipients.length > 0 && ( +
+

Uncompleted

+ {uncompletedRecipients.map((recipient: Recipient) => ( + + ))} +
+ )} + ); }; diff --git a/apps/web/src/components/document/document-read-only-fields.tsx b/apps/web/src/components/document/document-read-only-fields.tsx new file mode 100644 index 000000000..530066fa8 --- /dev/null +++ b/apps/web/src/components/document/document-read-only-fields.tsx @@ -0,0 +1,113 @@ +'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>({}); + + const handleHideField = (fieldId: string) => { + setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true })); + }; + + return ( + + {fields.map( + (field) => + !hiddenFieldIds[field.secondaryId] && ( + +
+ + + {extractInitials(field.Recipient.name || field.Recipient.email)} + + + } + contentProps={{ + className: 'flex w-fit flex-col py-2.5 text-sm', + }} + > +

+ + {field.Recipient.name + ? `${field.Recipient.name} (${field.Recipient.email})` + : field.Recipient.email}{' '} + + inserted a {FRIENDLY_FIELD_TYPE[field.type].toLowerCase()} +

+ + +
+
+ +
+ {match(field) + .with({ type: FieldType.SIGNATURE }, (field) => + field.Signature?.signatureImageAsBase64 ? ( + Signature + ) : ( +

+ {field.Signature?.typedSignature} +

+ ), + ) + .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()} +
+
+ ), + )} +
+ ); +}; diff --git a/packages/lib/server-only/field/get-completed-fields-for-document.ts b/packages/lib/server-only/field/get-completed-fields-for-document.ts new file mode 100644 index 000000000..304be95ba --- /dev/null +++ b/packages/lib/server-only/field/get-completed-fields-for-document.ts @@ -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, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/field/get-completed-fields-for-token.ts b/packages/lib/server-only/field/get-completed-fields-for-token.ts new file mode 100644 index 000000000..d84fa1343 --- /dev/null +++ b/packages/lib/server-only/field/get-completed-fields-for-token.ts @@ -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, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 8ae5fecaf..79a3f6f25 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -89,6 +89,10 @@ export const createDocumentFromTemplate = async ({ const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email); + if (!documentRecipient) { + throw new Error('Recipient not found.'); + } + return { type: field.type, page: field.page, @@ -99,7 +103,7 @@ export const createDocumentFromTemplate = async ({ customText: field.customText, inserted: field.inserted, documentId: document.id, - recipientId: documentRecipient?.id || null, + recipientId: documentRecipient.id, }; }), }); diff --git a/packages/lib/server-only/template/duplicate-template.ts b/packages/lib/server-only/template/duplicate-template.ts index 97b3f0a0b..963d78bde 100644 --- a/packages/lib/server-only/template/duplicate-template.ts +++ b/packages/lib/server-only/template/duplicate-template.ts @@ -81,6 +81,10 @@ export const duplicateTemplate = async ({ (doc) => doc.email === recipient?.email, ); + if (!duplicatedTemplateRecipient) { + throw new Error('Recipient not found.'); + } + return { type: field.type, page: field.page, @@ -91,7 +95,7 @@ export const duplicateTemplate = async ({ customText: field.customText, inserted: field.inserted, templateId: duplicatedTemplate.id, - recipientId: duplicatedTemplateRecipient?.id || null, + recipientId: duplicatedTemplateRecipient.id, }; }), }); diff --git a/packages/lib/types/fields.ts b/packages/lib/types/fields.ts new file mode 100644 index 000000000..1b999310d --- /dev/null +++ b/packages/lib/types/fields.ts @@ -0,0 +1,3 @@ +import type { getCompletedFieldsForToken } from '../server-only/field/get-completed-fields-for-token'; + +export type CompletedField = Awaited>[number]; diff --git a/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql new file mode 100644 index 000000000..62845de28 --- /dev/null +++ b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `recipientId` on table `Field` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Field" ALTER COLUMN "recipientId" SET NOT NULL; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 35d429779..c0e68d53f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -386,7 +386,7 @@ model Field { secondaryId String @unique @default(cuid()) documentId Int? templateId Int? - recipientId Int? + recipientId Int type FieldType page Int positionX Decimal @default(0) @@ -397,7 +397,7 @@ model Field { inserted Boolean Document Document? @relation(fields: [documentId], 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? @@index([documentId]) diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx index e40b2e3d9..ce62443de 100644 --- a/packages/ui/components/field/field.tsx +++ b/packages/ui/components/field/field.tsx @@ -19,6 +19,7 @@ export type FieldContainerPortalProps = { field: Field; className?: string; children: React.ReactNode; + cardClassName?: string; }; 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 ref = React.useRef(null); @@ -78,6 +79,7 @@ export function FieldRootContainer({ field, children }: FieldContainerPortalProp { 'border-orange-300 ring-1 ring-orange-300': !field.inserted && isValidating, }, + cardClassName, )} ref={ref} data-inserted={field.inserted ? 'true' : 'false'} diff --git a/packages/ui/primitives/popover.tsx b/packages/ui/primitives/popover.tsx index e84f6cc6d..62462322b 100644 --- a/packages/ui/primitives/popover.tsx +++ b/packages/ui/primitives/popover.tsx @@ -30,4 +30,66 @@ const PopoverContent = React.forwardRef< PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent }; +type PopoverHoverProps = { + trigger: React.ReactNode; + children: React.ReactNode; + contentProps?: React.ComponentPropsWithoutRef; +}; + +const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) => { + const [open, setOpen] = React.useState(false); + + const isControlled = React.useRef(false); + const isMouseOver = React.useRef(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 ( + + + {trigger} + + + + {children} + + + ); +}; + +export { Popover, PopoverTrigger, PopoverContent, PopoverHover }; From 6e09a4700b065d23c3367b5b00321fd7654fe040 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 19 Apr 2024 16:17:32 +0700 Subject: [PATCH 13/27] 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 ## 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. --- .../documents/[id]/document-page-view.tsx | 6 +++- .../[id]/edit/document-edit-page-view.tsx | 6 +++- .../documents/data-table-action-dropdown.tsx | 2 +- .../app/(dashboard)/documents/data-table.tsx | 7 +++- .../src/app/(signing)/sign/[token]/page.tsx | 7 +++- .../avatar/avatar-with-recipient.tsx | 35 ++++++++++--------- .../avatar/stack-avatars-with-tooltip.tsx | 22 +++++++++--- .../document/complete-document-with-token.ts | 4 +-- .../field/remove-signed-field-with-token.ts | 4 +-- .../field/sign-field-with-token.ts | 8 ++--- packages/prisma/seed/documents.ts | 17 ++++----- 11 files changed, 77 insertions(+), 41 deletions(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx index e20c88a27..fc1022cfa 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -118,7 +118,11 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
- + {recipients.length} Recipient(s)
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx index 8a78ca9aa..5c2a64870 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx @@ -92,7 +92,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
- + {recipients.length} Recipient(s)
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index a43d37af7..c67890dfe 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -114,7 +114,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr Action - {recipient && recipient?.role !== RecipientRole.CC && ( + {!isDraft && recipient && recipient?.role !== RecipientRole.CC && ( {recipient?.role === RecipientRole.VIEWER && ( diff --git a/apps/web/src/app/(dashboard)/documents/data-table.tsx b/apps/web/src/app/(dashboard)/documents/data-table.tsx index ec595641e..c49cdf6ab 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table.tsx @@ -76,7 +76,12 @@ export const DocumentsDataTable = ({ { header: 'Recipient', accessorKey: 'recipient', - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { header: 'Status', diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index e83f675ce..bd46898af 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -47,7 +47,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp getRecipientByToken({ token }).catch(() => null), ]); - if (!document || !document.documentData || !recipient) { + if ( + !document || + !document.documentData || + !recipient || + document.status === DocumentStatus.DRAFT + ) { return notFound(); } diff --git a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx index 69dd88d79..cd7cd2305 100644 --- a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx +++ b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx @@ -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 { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; +import { DocumentStatus } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -15,18 +16,21 @@ import { StackAvatar } from './stack-avatar'; export type AvatarWithRecipientProps = { recipient: Recipient; + documentStatus: DocumentStatus; }; -export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { +export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRecipientProps) { const [, copy] = useCopyToClipboard(); const { toast } = useToast(); + const signingToken = documentStatus === DocumentStatus.PENDING ? recipient.token : null; + const onRecipientClick = () => { - if (!recipient.token) { + if (!signingToken) { return; } - void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => { + void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${signingToken}`).then(() => { toast({ title: 'Copied to clipboard', description: 'The signing link has been copied to your clipboard.', @@ -37,10 +41,10 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { return (
-
-
-

{recipient.email}

-

- {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} -

-
+ +
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

); diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 10f7d1e6a..7a269d036 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -5,7 +5,7 @@ import { useRef, useState } from 'react'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; -import type { Recipient } from '@documenso/prisma/client'; +import type { DocumentStatus, Recipient } from '@documenso/prisma/client'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; import { AvatarWithRecipient } from './avatar-with-recipient'; @@ -13,12 +13,14 @@ import { StackAvatar } from './stack-avatar'; import { StackAvatars } from './stack-avatars'; export type StackAvatarsWithTooltipProps = { + documentStatus: DocumentStatus; recipients: Recipient[]; position?: 'top' | 'bottom'; children?: React.ReactNode; }; export const StackAvatarsWithTooltip = ({ + documentStatus, recipients, position, children, @@ -120,7 +122,11 @@ export const StackAvatarsWithTooltip = ({

Waiting

{waitingRecipients.map((recipient: Recipient) => ( - + ))}
)} @@ -129,7 +135,11 @@ export const StackAvatarsWithTooltip = ({

Opened

{openedRecipients.map((recipient: Recipient) => ( - + ))}
)} @@ -138,7 +148,11 @@ export const StackAvatarsWithTooltip = ({

Uncompleted

{uncompletedRecipients.map((recipient: Recipient) => ( - + ))}
)} diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index 8e3b56002..d16b83ea1 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -49,8 +49,8 @@ export const completeDocumentWithToken = async ({ const document = await getDocument({ token, documentId }); - if (document.status === DocumentStatus.COMPLETED) { - throw new Error(`Document ${document.id} has already been completed`); + if (document.status !== DocumentStatus.PENDING) { + throw new Error(`Document ${document.id} must be pending`); } if (document.Recipient.length === 0) { diff --git a/packages/lib/server-only/field/remove-signed-field-with-token.ts b/packages/lib/server-only/field/remove-signed-field-with-token.ts index 6548ae0f1..46d04dd58 100644 --- a/packages/lib/server-only/field/remove-signed-field-with-token.ts +++ b/packages/lib/server-only/field/remove-signed-field-with-token.ts @@ -36,8 +36,8 @@ export const removeSignedFieldWithToken = async ({ throw new Error(`Document not found for field ${field.id}`); } - if (document.status === DocumentStatus.COMPLETED) { - throw new Error(`Document ${document.id} has already been completed`); + if (document.status !== DocumentStatus.PENDING) { + throw new Error(`Document ${document.id} must be pending`); } if (recipient?.signingStatus === SigningStatus.SIGNED) { diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index b8a5ccf8f..359a5da68 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -58,14 +58,14 @@ export const signFieldWithToken = async ({ 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) { 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) { throw new Error(`Recipient ${recipient.id} has already signed`); } diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts index 6c1e698c5..2e6462daa 100644 --- a/packages/prisma/seed/documents.ts +++ b/packages/prisma/seed/documents.ts @@ -342,14 +342,15 @@ export const seedPendingDocumentWithFullFields = async ({ }, }); - const latestDocument = updateDocumentOptions - ? await prisma.document.update({ - where: { - id: document.id, - }, - data: updateDocumentOptions, - }) - : document; + const latestDocument = await prisma.document.update({ + where: { + id: document.id, + }, + data: { + ...updateDocumentOptions, + status: DocumentStatus.PENDING, + }, + }); return { document: latestDocument, From bd40e633920b2304e48dc12585d3999d35e20fe9 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 19 Apr 2024 17:37:38 +0700 Subject: [PATCH 14/27] fix: update document deletion logic (#1100) --- .../app/(marketing)/singleplayer/client.tsx | 1 + .../[id]/document-page-view-dropdown.tsx | 30 +-- .../documents/[id]/document-page-view.tsx | 7 +- .../documents/data-table-action-dropdown.tsx | 41 ++-- .../app/(dashboard)/documents/data-table.tsx | 2 +- .../documents/delete-document-dialog.tsx | 104 ++++++--- .../documents/documents-page-view.tsx | 4 +- .../app/(dashboard)/documents/empty-state.tsx | 5 +- .../e2e/document-flow/signers-step.spec.ts | 4 +- .../document-flow/stepper-component.spec.ts | 2 +- .../e2e/documents/delete-documents.spec.ts | 172 +++++++++++++-- packages/app-tests/e2e/fixtures/documents.ts | 17 ++ .../e2e/teams/team-documents.spec.ts | 157 +++++++++++--- .../template-document-cancel.tsx | 4 + .../server-only/document/delete-document.ts | 197 ++++++++++++------ .../server-only/document/find-documents.ts | 55 ++++- .../lib/server-only/document/get-stats.ts | 11 +- .../migration.sql | 13 ++ packages/prisma/schema.prisma | 35 ++-- .../primitives/document-flow/add-signers.tsx | 4 +- 20 files changed, 651 insertions(+), 214 deletions(-) create mode 100644 packages/app-tests/e2e/fixtures/documents.ts create mode 100644 packages/prisma/migrations/20240408142543_add_recipient_document_delete/migration.sql diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 3f1c11259..e20b94887 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -158,6 +158,7 @@ export const SinglePlayerClient = () => { expired: null, signedAt: null, readStatus: 'OPENED', + documentDeletedAt: null, signingStatus: 'NOT_SIGNED', sendStatus: 'NOT_SENT', role: 'SIGNER', diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx index 0fb592ea1..35dbaa8f1 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-dropdown.tsx @@ -19,7 +19,7 @@ import { useSession } from 'next-auth/react'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; 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 { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { @@ -41,7 +41,7 @@ export type DocumentPageViewDropdownProps = { Recipient: Recipient[]; team: Pick | null; }; - team?: Pick; + team?: Pick & { teamEmail: TeamEmail | null }; }; 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 isDraft = document.status === DocumentStatus.DRAFT; + const isDeleted = document.deletedAt !== null; const isComplete = document.status === DocumentStatus.COMPLETED; - const isDocumentDeletable = isOwner; const isCurrentTeamDocument = team && document.team?.url === team.url; + const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const documentsPath = formatDocumentsPath(team?.url); @@ -127,7 +128,10 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro Duplicate - setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> + setDeleteDialogOpen(true)} + disabled={Boolean(!canManageDocument && team?.teamEmail) || isDeleted} + > Delete @@ -154,15 +158,15 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro /> - {isDocumentDeletable && ( - - )} + + {isDuplicateDialogOpen && ( { @@ -127,6 +128,8 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
)} + + {document.deletedAt && Document deleted}
diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index c67890dfe..aed95662b 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -15,7 +15,6 @@ import { Pencil, Share, Trash2, - XCircle, } from 'lucide-react'; import { useSession } from 'next-auth/react'; @@ -45,7 +44,7 @@ export type DataTableActionDropdownProps = { Recipient: Recipient[]; team: Pick | null; }; - team?: Pick; + team?: Pick & { teamEmail?: string }; }; export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => { @@ -67,8 +66,8 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr // const isPending = row.status === DocumentStatus.PENDING; const isComplete = row.status === DocumentStatus.COMPLETED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; - const isDocumentDeletable = isOwner; const isCurrentTeamDocument = team && row.team?.url === team.url; + const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const documentsPath = formatDocumentsPath(team?.url); @@ -107,7 +106,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr return ( - + @@ -141,7 +140,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr )} - + Edit @@ -158,14 +157,18 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr Duplicate - + {/* No point displaying this if there's no functionality. */} + {/* Void - + */} - setDeleteDialogOpen(true)} disabled={!isDocumentDeletable}> + setDeleteDialogOpen(true)} + disabled={Boolean(!canManageDocument && team?.teamEmail)} + > - Delete + {canManageDocument ? 'Delete' : 'Hide'} Share @@ -186,16 +189,16 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr /> - {isDocumentDeletable && ( - - )} + + {isDuplicateDialogOpen && ( ; showSenderColumn?: boolean; - team?: Pick; + team?: Pick & { teamEmail?: string }; }; export const DocumentsDataTable = ({ diff --git a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx index 59fd21e60..558d39558 100644 --- a/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/delete-document-dialog.tsx @@ -2,8 +2,11 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; +import { match } from 'ts-pattern'; + import { DocumentStatus } from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -23,6 +26,7 @@ type DeleteDocumentDialogProps = { status: DocumentStatus; documentTitle: string; teamId?: number; + canManageDocument: boolean; }; export const DeleteDocumentDialog = ({ @@ -32,6 +36,7 @@ export const DeleteDocumentDialog = ({ status, documentTitle, teamId, + canManageDocument, }: DeleteDocumentDialogProps) => { const router = useRouter(); @@ -83,47 +88,82 @@ export const DeleteDocumentDialog = ({ !isLoading && onOpenChange(value)}> - Are you sure you want to delete "{documentTitle}"? + Are you sure? - Please note that this action is irreversible. Once confirmed, your document will be - permanently deleted. + You are about to {canManageDocument ? 'delete' : 'hide'}{' '} + "{documentTitle}" - {status !== DocumentStatus.DRAFT && ( -
- -
+ {canManageDocument ? ( + + {match(status) + .with(DocumentStatus.DRAFT, () => ( + + Please note that this action is irreversible. Once confirmed, + this document will be permanently deleted. + + )) + .with(DocumentStatus.PENDING, () => ( + +

+ Please note that this action is irreversible. +

+ +

Once confirmed, the following will occur:

+ +
    +
  • Document will be permanently deleted
  • +
  • Document signing process will be cancelled
  • +
  • All inserted signatures will be voided
  • +
  • All recipients will be notified
  • +
+
+ )) + .with(DocumentStatus.COMPLETED, () => ( + +

By deleting this document, the following will occur:

+ +
    +
  • The document will be hidden from your account
  • +
  • Recipients will still retain their copy of the document
  • +
+
+ )) + .exhaustive()} +
+ ) : ( + + + Please contact support if you would like to revert this action. + + + )} + + {status !== DocumentStatus.DRAFT && canManageDocument && ( + )} -
- + - -
+
diff --git a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx index 9059b8e88..84f6bfe3f 100644 --- a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx @@ -41,7 +41,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 20; 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 = { user, diff --git a/apps/web/src/app/(dashboard)/documents/empty-state.tsx b/apps/web/src/app/(dashboard)/documents/empty-state.tsx index b6d2f74e2..e1af23bf2 100644 --- a/apps/web/src/app/(dashboard)/documents/empty-state.tsx +++ b/apps/web/src/app/(dashboard)/documents/empty-state.tsx @@ -37,7 +37,10 @@ export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => { })); return ( -
+
diff --git a/packages/app-tests/e2e/document-flow/signers-step.spec.ts b/packages/app-tests/e2e/document-flow/signers-step.spec.ts index 30d6ba11f..8676d05ed 100644 --- a/packages/app-tests/e2e/document-flow/signers-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/signers-step.spec.ts @@ -45,7 +45,7 @@ test.describe('[EE_ONLY]', () => { 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'); // Display advanced settings. 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.getByRole('button', { name: 'Add Signer' }).click(); 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. await expect(page.getByLabel('Show advanced settings')).toBeHidden(); diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index c2ae0618c..07aee6a30 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -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.getByRole('button', { name: 'Add Signer' }).click(); 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(); diff --git a/packages/app-tests/e2e/documents/delete-documents.spec.ts b/packages/app-tests/e2e/documents/delete-documents.spec.ts index 3658f1bc9..32f385df5 100644 --- a/packages/app-tests/e2e/documents/delete-documents.spec.ts +++ b/packages/app-tests/e2e/documents/delete-documents.spec.ts @@ -8,6 +8,7 @@ import { import { seedUser } from '@documenso/prisma/seed/users'; import { apiSignin, apiSignout } from '../fixtures/authentication'; +import { checkDocumentTabCount } from '../fixtures/documents'; test.describe.configure({ mode: 'serial' }); @@ -74,7 +75,7 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip email: sender.email, }); - // open actions menu + // Open document action menu. await page .locator('tr', { hasText: 'Document 1 - Completed' }) .getByRole('cell', { name: 'Download' }) @@ -115,7 +116,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients' email: sender.email, }); - // open actions menu + // Open document action menu. await page.locator('tr', { hasText: 'Document 1 - Pending' }).getByRole('button').nth(1).click(); // 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 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 }); } }); -test('[DOCUMENTS]: deleting a draft document should remove it without additional prompting', async ({ - page, -}) => { +test('[DOCUMENTS]: deleting draft documents should permanently remove it', async ({ page }) => { const { sender } = await seedDeleteDocumentsTestRequirements(); await apiSignin({ @@ -156,11 +148,10 @@ test('[DOCUMENTS]: deleting a draft document should remove it without additional email: sender.email, }); - // open actions menu + // Open document action menu. await page .locator('tr', { hasText: 'Document 1 - Draft' }) - .getByRole('cell', { name: 'Edit' }) - .getByRole('button') + .getByTestId('document-table-action-btn') .click(); // 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 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); }); diff --git a/packages/app-tests/e2e/fixtures/documents.ts b/packages/app-tests/e2e/fixtures/documents.ts new file mode 100644 index 000000000..f7e0bd391 --- /dev/null +++ b/packages/app-tests/e2e/fixtures/documents.ts @@ -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}`); +}; diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts index 8f70befc8..6cea6445d 100644 --- a/packages/app-tests/e2e/teams/team-documents.spec.ts +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -1,4 +1,3 @@ -import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; 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 { apiSignin, apiSignout } from '../fixtures/authentication'; +import { checkDocumentTabCount } from '../fixtures/documents'; 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 }) => { 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); }); -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 }) => { 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'); }); + +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); +}); diff --git a/packages/email/template-components/template-document-cancel.tsx b/packages/email/template-components/template-document-cancel.tsx index 885cb6c80..dff275de2 100644 --- a/packages/email/template-components/template-document-cancel.tsx +++ b/packages/email/template-components/template-document-cancel.tsx @@ -23,6 +23,10 @@ export const TemplateDocumentCancel = ({
"{documentName}" + + All signatures have been voided. + + You don't need to sign it anymore. diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index b0b1ad682..a097d76e9 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -6,6 +6,7 @@ import { mailer } from '@documenso/email/mailer'; import { render } from '@documenso/email/render'; import DocumentCancelTemplate from '@documenso/email/templates/document-cancel'; import { prisma } from '@documenso/prisma'; +import type { Document, DocumentMeta, Recipient, User } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; @@ -27,110 +28,178 @@ export const deleteDocument = async ({ teamId, requestMetadata, }: DeleteDocumentOptions) => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + const document = await prisma.document.findUnique({ where: { id, - ...(teamId - ? { - team: { - id: teamId, - members: { - some: { - userId, - }, - }, - }, - } - : { - userId, - teamId: null, - }), }, include: { Recipient: true, documentMeta: true, - User: true, + team: { + select: { + members: true, + }, + }, }, }); - if (!document) { + if (!document || (teamId !== undefined && teamId !== document.teamId)) { 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 (status === DocumentStatus.DRAFT) { + if (!isUserOwner && !isUserTeamMember && !userRecipient) { + 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) => { - // 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({ data: createDocumentAuditLogData({ - documentId: id, + documentId: document.id, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, user, requestMetadata, 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 - if (status === DocumentStatus.PENDING && document.Recipient.length > 0) { - 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 }), - }); - }), - ); - } - - // If the document is not a draft, only soft-delete. - return await prisma.$transaction(async (tx) => { + // Hard delete draft and pending documents. + const deletedDocument = await prisma.$transaction(async (tx) => { + // Currently redundant since deleting a document will delete the audit logs. + // However may be useful if we disassociate audit logs and documents if required. await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ - documentId: id, + documentId: document.id, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED, user, requestMetadata, data: { - type: 'SOFT', + type: 'HARD', }, }), }); - return await tx.document.update({ + return await tx.document.delete({ where: { - id, - }, - data: { - deletedAt: new Date().toISOString(), + id: document.id, + status: { + not: DocumentStatus.COMPLETED, + }, }, }); }); + + // 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; }; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index f34cc4c2c..c8b06236b 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -94,24 +94,65 @@ export const findDocuments = async ({ }; } - const whereClause: Prisma.DocumentWhereInput = { - ...termFilters, - ...filters, + let deletedFilter: Prisma.DocumentWhereInput = { AND: { OR: [ { - status: ExtendedDocumentStatus.COMPLETED, + userId: user.id, + deletedAt: null, }, { - status: { - not: ExtendedDocumentStatus.COMPLETED, + Recipient: { + 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) { const daysAgo = parseInt(period.replace(/d$/, ''), 10); diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index db38fa79d..1afdbcbf2 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -72,6 +72,7 @@ type GetCountsOption = { const getCounts = async ({ user, createdAt }: GetCountsOption) => { return Promise.all([ + // Owner counts. prisma.document.groupBy({ by: ['status'], _count: { @@ -84,6 +85,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { deletedAt: null, }, }), + // Not signed counts. prisma.document.groupBy({ by: ['status'], _count: { @@ -95,12 +97,13 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { some: { email: user.email, signingStatus: SigningStatus.NOT_SIGNED, + documentDeletedAt: null, }, }, createdAt, - deletedAt: null, }, }), + // Has signed counts. prisma.document.groupBy({ by: ['status'], _count: { @@ -120,9 +123,9 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { some: { email: user.email, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, - deletedAt: null, }, { status: ExtendedDocumentStatus.COMPLETED, @@ -130,6 +133,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => { some: { email: user.email, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, }, @@ -198,6 +202,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { some: { email: teamEmail, signingStatus: SigningStatus.NOT_SIGNED, + documentDeletedAt: null, }, }, deletedAt: null, @@ -219,6 +224,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { some: { email: teamEmail, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, deletedAt: null, @@ -229,6 +235,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => { some: { email: teamEmail, signingStatus: SigningStatus.SIGNED, + documentDeletedAt: null, }, }, deletedAt: null, diff --git a/packages/prisma/migrations/20240408142543_add_recipient_document_delete/migration.sql b/packages/prisma/migrations/20240408142543_add_recipient_document_delete/migration.sql new file mode 100644 index 000000000..6bbb11cd9 --- /dev/null +++ b/packages/prisma/migrations/20240408142543_add_recipient_document_delete/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 35d429779..8971f837f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -347,23 +347,24 @@ enum RecipientRole { } model Recipient { - id Int @id @default(autoincrement()) - documentId Int? - templateId Int? - email String @db.VarChar(255) - name String @default("") @db.VarChar(255) - token String - expired DateTime? - signedAt DateTime? - authOptions Json? - role RecipientRole @default(SIGNER) - readStatus ReadStatus @default(NOT_OPENED) - signingStatus SigningStatus @default(NOT_SIGNED) - sendStatus SendStatus @default(NOT_SENT) - Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) - Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) - Field Field[] - Signature Signature[] + id Int @id @default(autoincrement()) + documentId Int? + templateId Int? + email String @db.VarChar(255) + name String @default("") @db.VarChar(255) + token String + documentDeletedAt DateTime? + expired DateTime? + signedAt DateTime? + authOptions Json? + role RecipientRole @default(SIGNER) + readStatus ReadStatus @default(NOT_OPENED) + signingStatus SigningStatus @default(NOT_SIGNED) + sendStatus SendStatus @default(NOT_SENT) + Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) + Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade) + Field Field[] + Signature Signature[] @@unique([documentId, email]) @@unique([templateId, email]) diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 25169bcec..b796f4328 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -247,9 +247,7 @@ export const AddSignersFormPartial = ({ 'col-span-4': showAdvancedSettings, })} > - {!showAdvancedSettings && index === 0 && ( - Name - )} + {!showAdvancedSettings && index === 0 && Name} Date: Fri, 19 Apr 2024 13:45:33 +0300 Subject: [PATCH 15/27] 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 ""` * the email content will be `You have initiated the document "" 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 ` has invited you to view/sign/approve ""` * the email content will be ` has invited you to view/sign/approve the document "".` ## 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) ## 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. --- .../template-document-invite.tsx | 17 +++++++++++++++-- packages/email/templates/document-invite.tsx | 7 ++++++- .../server-only/document/resend-document.tsx | 17 +++++++++++++++-- .../lib/server-only/document/send-document.tsx | 17 +++++++++++++++-- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx index b958e9029..b99b1a1b4 100644 --- a/packages/email/template-components/template-document-invite.tsx +++ b/packages/email/template-components/template-document-invite.tsx @@ -11,6 +11,7 @@ export interface TemplateDocumentInviteProps { signDocumentLink: string; assetBaseUrl: string; role: RecipientRole; + selfSigner: boolean; } export const TemplateDocumentInvite = ({ @@ -19,6 +20,7 @@ export const TemplateDocumentInvite = ({ signDocumentLink, assetBaseUrl, role, + selfSigner, }: TemplateDocumentInviteProps) => { const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role]; @@ -28,8 +30,19 @@ export const TemplateDocumentInvite = ({
- {inviterName} has invited you to {actionVerb.toLowerCase()} -
"{documentName}" + {selfSigner ? ( + <> + {`Please ${actionVerb.toLowerCase()} your document`} +
+ {`"${documentName}"`} + + ) : ( + <> + {`${inviterName} has invited you to ${actionVerb.toLowerCase()}`} +
+ {`"${documentName}"`} + + )}
diff --git a/packages/email/templates/document-invite.tsx b/packages/email/templates/document-invite.tsx index d3bceb872..52a40d804 100644 --- a/packages/email/templates/document-invite.tsx +++ b/packages/email/templates/document-invite.tsx @@ -22,6 +22,7 @@ import { TemplateFooter } from '../template-components/template-footer'; export type DocumentInviteEmailTemplateProps = Partial & { customBody?: string; role: RecipientRole; + selfSigner?: boolean; }; export const DocumentInviteEmailTemplate = ({ @@ -32,10 +33,13 @@ export const DocumentInviteEmailTemplate = ({ assetBaseUrl = 'http://localhost:3002', customBody, role, + selfSigner = false, }: DocumentInviteEmailTemplateProps) => { 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) => { return new URL(path, assetBaseUrl).toString(); @@ -71,6 +75,7 @@ export const DocumentInviteEmailTemplate = ({ signDocumentLink={signDocumentLink} assetBaseUrl={assetBaseUrl} role={role} + selfSigner={selfSigner} />
diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index ebf140007..500c5395a 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -88,6 +88,11 @@ export const resendDocument = async ({ const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; 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 = { 'signer.name': name, @@ -104,12 +109,20 @@ export const resendDocument = async ({ inviterEmail: user.email, assetBaseUrl, signDocumentLink, - customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), + customBody: renderCustomEmailTemplate( + selfSigner ? selfSignerCustomEmail : customEmail?.message || '', + customEmailTemplate, + ), role: recipient.role, + selfSigner, }); 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( async (tx) => { await mailer.sendMail({ @@ -123,7 +136,7 @@ export const resendDocument = async ({ }, subject: customEmail?.subject ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : `Please ${actionVerb.toLowerCase()} this document`, + : emailSubject, html: render(template), text: render(template, { plainText: true }), }); diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index acbcc499f..5bb7e2352 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -127,6 +127,11 @@ export const sendDocument = async ({ const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role]; 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 = { 'signer.name': name, @@ -143,12 +148,20 @@ export const sendDocument = async ({ inviterEmail: user.email, assetBaseUrl, signDocumentLink, - customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), + customBody: renderCustomEmailTemplate( + selfSigner ? selfSignerCustomEmail : customEmail?.message || '', + customEmailTemplate, + ), role: recipient.role, + selfSigner, }); const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role]; + const emailSubject = selfSigner + ? `Please ${actionVerb.toLowerCase()} your document` + : `Please ${actionVerb.toLowerCase()} this document`; + await prisma.$transaction( async (tx) => { await mailer.sendMail({ @@ -162,7 +175,7 @@ export const sendDocument = async ({ }, subject: customEmail?.subject ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) - : `Please ${actionVerb.toLowerCase()} this document`, + : emailSubject, html: render(template), text: render(template, { plainText: true }), }); From f6e6dac46c681f6666ec90cd5245482f4030c0a5 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 19 Apr 2024 17:58:32 +0700 Subject: [PATCH 16/27] fix: update migration to drop invalid fields --- .../migration.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql index 62845de28..ee027d90e 100644 --- a/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql +++ b/packages/prisma/migrations/20240418140819_remove_impossible_field_optional_states/migration.sql @@ -4,5 +4,8 @@ - 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; From 4b90adde6baf5714f9bb27f655af0981c4309d7a Mon Sep 17 00:00:00 2001 From: Catalin Pit <25515812+catalinpit@users.noreply.github.com> Date: Fri, 19 Apr 2024 14:04:11 +0300 Subject: [PATCH 17/27] 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. ## Summary by CodeRabbit - **New Features** - Implemented functionality to download signed documents directly from the app. --- packages/api/v1/contract.ts | 12 ++++++ packages/api/v1/implementation.ts | 67 ++++++++++++++++++++++++++++++- packages/api/v1/schema.ts | 4 ++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts index 162cdcf9d..ca2b6e2f5 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -11,6 +11,7 @@ import { ZDeleteDocumentMutationSchema, ZDeleteFieldMutationSchema, ZDeleteRecipientMutationSchema, + ZDownloadDocumentSuccessfulSchema, ZGetDocumentsQuerySchema, ZSendDocumentForSigningMutationSchema, ZSuccessfulDocumentResponseSchema, @@ -51,6 +52,17 @@ export const ApiContractV1 = c.router( 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: { method: 'POST', path: '/api/v1/documents', diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index d9bc1a6d7..8ee0350bd 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -23,7 +23,10 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/ import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { getFile } from '@documenso/lib/universal/upload/get-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 { 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) => { const { id: documentId } = args.params; diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index 01f6e2d58..be0ea1271 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -53,6 +53,10 @@ export const ZUploadDocumentSuccessfulSchema = z.object({ key: z.string(), }); +export const ZDownloadDocumentSuccessfulSchema = z.object({ + downloadUrl: z.string(), +}); + export type TUploadDocumentSuccessfulSchema = z.infer; export const ZCreateDocumentMutationSchema = z.object({ From afaeba97393094017dc1c85f544ea4b6bf4cc16d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 22 Apr 2024 13:31:49 +0700 Subject: [PATCH 18/27] fix: resize fields --- .../document/document-read-only-fields.tsx | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/document/document-read-only-fields.tsx b/apps/web/src/components/document/document-read-only-fields.tsx index 530066fa8..95a907d8f 100644 --- a/apps/web/src/components/document/document-read-only-fields.tsx +++ b/apps/web/src/components/document/document-read-only-fields.tsx @@ -75,7 +75,7 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
-
+
{match(field) .with({ type: FieldType.SIGNATURE }, (field) => field.Signature?.signatureImageAsBase64 ? ( @@ -90,18 +90,17 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl

), ) - .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: 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()}
From 87423e240af3c915b7ec70eca865f3fc1f3a5fe1 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 24 Apr 2024 17:32:11 +1000 Subject: [PATCH 19/27] chore: update foreign key constraints --- .../migration.sql | 23 +++++++++++++++++++ packages/prisma/schema.prisma | 10 ++++---- 2 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 packages/prisma/migrations/20240424072655_update_foreign_key_constraints/migration.sql diff --git a/packages/prisma/migrations/20240424072655_update_foreign_key_constraints/migration.sql b/packages/prisma/migrations/20240424072655_update_foreign_key_constraints/migration.sql new file mode 100644 index 000000000..89c38943d --- /dev/null +++ b/packages/prisma/migrations/20240424072655_update_foreign_key_constraints/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 8971f837f..97b6e9eeb 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -98,7 +98,7 @@ model PasswordResetToken { createdAt DateTime @default(now()) expiry DateTime userId Int - User User @relation(fields: [userId], references: [id]) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Passkey { @@ -415,7 +415,7 @@ model Signature { typedSignature String? 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]) } @@ -457,7 +457,7 @@ model Team { emailVerification TeamEmailVerification? transferVerification TeamTransferVerification? - owner User @relation(fields: [ownerUserId], references: [id]) + owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade) subscription Subscription? document Document[] @@ -483,7 +483,7 @@ model TeamMember { createdAt DateTime @default(now()) role TeamMemberRole 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) @@unique([userId, teamId]) @@ -564,5 +564,5 @@ model SiteSettings { data Json lastModifiedByUserId Int? lastModifiedAt DateTime @default(now()) - lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id]) + lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id], onDelete: SetNull) } From 713cd09a063d1d97db6e753fb0b2edef29b5357f Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 24 Apr 2024 19:07:18 +1000 Subject: [PATCH 20/27] fix: downgrade playwright --- package-lock.json | 74 +++++++++++++++++++++++---------------- package.json | 2 +- packages/lib/package.json | 4 +-- 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb03b3a67..83d9523c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", - "playwright": "^1.43.0", + "playwright": "1.41.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" @@ -4702,19 +4702,6 @@ "node": ">=14" } }, - "node_modules/@playwright/browser-chromium": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz", - "integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "playwright-core": "1.43.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/@playwright/test": { "version": "1.40.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", @@ -17673,11 +17660,11 @@ } }, "node_modules/playwright": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", - "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0.tgz", + "integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==", "dependencies": { - "playwright-core": "1.43.0" + "playwright-core": "1.41.0" }, "bin": { "playwright": "cli.js" @@ -17689,17 +17676,6 @@ "fsevents": "2.3.2" } }, - "node_modules/playwright-core": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", - "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/playwright/node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -17713,6 +17689,17 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/playwright/node_modules/playwright-core": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz", + "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -24981,7 +24968,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", - "playwright": "^1.43.0", + "playwright": "1.41.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -24989,10 +24976,23 @@ "zod": "^3.22.4" }, "devDependencies": { - "@playwright/browser-chromium": "^1.43.0", + "@playwright/browser-chromium": "1.41.0", "@types/luxon": "^3.3.1" } }, + "packages/lib/node_modules/@playwright/browser-chromium": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.41.0.tgz", + "integrity": "sha512-TaHfh3rDsz4+tVKdMMo4kdFOk8/4U6cPyMXHhoiJVmhOhjHXjR0qPMoa5gz5jDGl478cn5SoXmtgKPgTDFuS0g==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "playwright-core": "1.41.0" + }, + "engines": { + "node": ">=16" + } + }, "packages/lib/node_modules/nanoid": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", @@ -25010,6 +25010,18 @@ "node": "^14 || ^16 || >=18" } }, + "packages/lib/node_modules/playwright-core": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz", + "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "packages/prettier-config": { "name": "@documenso/prettier-config", "version": "0.0.0", diff --git a/package.json b/package.json index 396b2ecfd..70ed541e1 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", - "playwright": "^1.43.0", + "playwright": "1.41.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" diff --git a/packages/lib/package.json b/packages/lib/package.json index 1aa7e431e..5e40e047b 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -39,7 +39,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", - "playwright": "^1.43.0", + "playwright": "1.41.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -48,6 +48,6 @@ }, "devDependencies": { "@types/luxon": "^3.3.1", - "@playwright/browser-chromium": "^1.43.0" + "@playwright/browser-chromium": "1.41.0" } } \ No newline at end of file From 41ed6c9ad7f34c9ea430808a8e4f5cf95d9e7037 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 24 Apr 2024 19:49:10 +0700 Subject: [PATCH 21/27] fix: disable cert download when document not complete --- .../documents/[id]/logs/document-logs-page-view.tsx | 6 +++++- .../documents/[id]/logs/download-certificate-button.tsx | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx index 2d786b9c9..0556fcd2d 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/document-logs-page-view.tsx @@ -133,7 +133,11 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
- +
diff --git a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx index 49a330b94..1f2028358 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/logs/download-certificate-button.tsx @@ -2,6 +2,7 @@ import { DownloadIcon } from 'lucide-react'; +import { DocumentStatus } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -10,11 +11,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export type DownloadCertificateButtonProps = { className?: string; documentId: number; + documentStatus: DocumentStatus; }; export const DownloadCertificateButton = ({ className, documentId, + documentStatus, }: DownloadCertificateButtonProps) => { const { toast } = useToast(); @@ -69,6 +72,7 @@ export const DownloadCertificateButton = ({ className={cn('w-full sm:w-auto', className)} loading={isLoading} variant="outline" + disabled={documentStatus !== DocumentStatus.COMPLETED} onClick={() => void onDownloadCertificatesClick()} > {!isLoading && } From e4cf9c82518a6f38ca1626c8899c89abe92efa46 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 24 Apr 2024 19:51:18 +0700 Subject: [PATCH 22/27] fix: add server logic --- packages/trpc/server/document-router/router.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index d12002674..64f3c2480 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -4,6 +4,7 @@ import { DateTime } from 'luxon'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; 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 { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; 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 { symmetricEncrypt } from '@documenso/lib/universal/crypto'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; +import { DocumentStatus } from '@documenso/prisma/client'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -413,6 +415,10 @@ export const documentRouter = router({ teamId, }); + if (document.status !== DocumentStatus.COMPLETED) { + throw new AppError('DOCUMENT_NOT_COMPLETE'); + } + const encrypted = encryptSecondaryData({ data: document.id.toString(), expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), From 4de122f814b4cbb50ddb9b838f42c5d198102489 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 24 Apr 2024 20:07:38 +0700 Subject: [PATCH 23/27] fix: hide account action reauth --- .../primitives/document-flow/add-settings.tsx | 22 ++++++++++++------- .../primitives/document-flow/add-signers.tsx | 16 ++++++++------ 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index ea962dee5..ce52e03c2 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -9,7 +9,11 @@ import { useForm } from 'react-hook-form'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth'; 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 { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; @@ -216,9 +220,9 @@ export const AddSettingsFormPartial = ({

    -
  • + {/*
  • Require account - The recipient must be signed in -
  • + */}
  • Require passkey - The recipient must have an account and passkey configured via their settings @@ -242,11 +246,13 @@ export const AddSettingsFormPartial = ({ - {Object.values(DocumentActionAuth).map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} + {Object.values(DocumentActionAuth) + .filter((auth) => auth !== DocumentAuth.ACCOUNT) + .map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} {/* Note: -1 is remapped in the Zod schema to the required value. */} None diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index b796f4328..2f9f2f234 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -302,10 +302,10 @@ export const AddSignersFormPartial = ({ global action signing authentication method configured in the "General Settings" step
  • -
  • + {/*
  • Require account - The recipient must be signed in -
  • + */}
  • Require passkey - The recipient must have an account and passkey configured via their settings @@ -326,11 +326,13 @@ export const AddSignersFormPartial = ({ {/* Note: -1 is remapped in the Zod schema to the required value. */} Inherit authentication method - {Object.values(RecipientActionAuth).map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} + {Object.values(RecipientActionAuth) + .filter((auth) => auth !== RecipientActionAuth.ACCOUNT) + .map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} From e1573465f6e66b67b47bb83bcb4e5fa0899a3711 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 25 Apr 2024 23:32:59 +0700 Subject: [PATCH 24/27] fix: hide team webhooks from users --- packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts index 121fc670d..0877d878f 100644 --- a/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts +++ b/packages/lib/server-only/webhooks/get-webhooks-by-user-id.ts @@ -4,6 +4,7 @@ export const getWebhooksByUserId = async (userId: number) => { return await prisma.webhook.findMany({ where: { userId, + teamId: null, }, orderBy: { createdAt: 'desc', From 88dedc98298a29d9c0438d9491ab45f3fc38e315 Mon Sep 17 00:00:00 2001 From: Mythie Date: Fri, 26 Apr 2024 13:18:31 +1000 Subject: [PATCH 25/27] fix: use cdp and upgrade playwright again --- package-lock.json | 26 +++++++++---------- package.json | 2 +- packages/lib/package.json | 4 +-- .../htmltopdf/get-certificate-pdf.ts | 4 ++- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 83d9523c5..e9e822cf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", - "playwright": "1.41.0", + "playwright": "1.43.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" @@ -17660,11 +17660,11 @@ } }, "node_modules/playwright": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.0.tgz", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", "integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==", "dependencies": { - "playwright-core": "1.41.0" + "playwright-core": "1.43.0" }, "bin": { "playwright": "cli.js" @@ -17690,8 +17690,8 @@ } }, "node_modules/playwright/node_modules/playwright-core": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", "bin": { "playwright-core": "cli.js" @@ -24968,7 +24968,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", - "playwright": "1.41.0", + "playwright": "1.43.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -24976,18 +24976,18 @@ "zod": "^3.22.4" }, "devDependencies": { - "@playwright/browser-chromium": "1.41.0", + "@playwright/browser-chromium": "1.43.0", "@types/luxon": "^3.3.1" } }, "packages/lib/node_modules/@playwright/browser-chromium": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.41.0.tgz", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz", "integrity": "sha512-TaHfh3rDsz4+tVKdMMo4kdFOk8/4U6cPyMXHhoiJVmhOhjHXjR0qPMoa5gz5jDGl478cn5SoXmtgKPgTDFuS0g==", "dev": true, "hasInstallScript": true, "dependencies": { - "playwright-core": "1.41.0" + "playwright-core": "1.43.0" }, "engines": { "node": ">=16" @@ -25011,8 +25011,8 @@ } }, "packages/lib/node_modules/playwright-core": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.0.tgz", + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", "dev": true, "bin": { diff --git a/package.json b/package.json index 70ed541e1..3480aae28 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "eslint-config-custom": "*", "husky": "^9.0.11", "lint-staged": "^15.2.2", - "playwright": "1.41.0", + "playwright": "1.43.0", "prettier": "^2.5.1", "rimraf": "^5.0.1", "turbo": "^1.9.3" diff --git a/packages/lib/package.json b/packages/lib/package.json index 5e40e047b..c6144df92 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -39,7 +39,7 @@ "next-auth": "4.24.5", "oslo": "^0.17.0", "pdf-lib": "^1.17.1", - "playwright": "1.41.0", + "playwright": "1.43.0", "react": "18.2.0", "remeda": "^1.27.1", "stripe": "^12.7.0", @@ -48,6 +48,6 @@ }, "devDependencies": { "@types/luxon": "^3.3.1", - "@playwright/browser-chromium": "1.41.0" + "@playwright/browser-chromium": "1.43.0" } } \ No newline at end of file diff --git a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts index a7182410e..dee40d41a 100644 --- a/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts +++ b/packages/lib/server-only/htmltopdf/get-certificate-pdf.ts @@ -18,7 +18,9 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions let browser: Browser; 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 { browser = await chromium.launch(); } From 481d739c37e7ba147b9c3120a1bac47ea0f7919c Mon Sep 17 00:00:00 2001 From: Mythie Date: Fri, 26 Apr 2024 13:25:16 +1000 Subject: [PATCH 26/27] chore: update package-lock --- package-lock.json | 84 ++++++++++++++++++++--------------------------- 1 file changed, 36 insertions(+), 48 deletions(-) diff --git a/package-lock.json b/package-lock.json index e9e822cf2..479463b25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4702,13 +4702,26 @@ "node": ">=14" } }, + "node_modules/@playwright/browser-chromium": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz", + "integrity": "sha512-F0S4KIqSqQqm9EgsdtWjaJRpgP8cD2vWZHPSB41YI00PtXUobiv/3AnYISeL7wNuTanND7giaXQ4SIjkcIq3KQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "playwright-core": "1.43.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@playwright/test": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", - "integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.1.tgz", + "integrity": "sha512-HgtQzFgNEEo4TE22K/X7sYTYNqEMMTZmFS8kTq6m8hXj+m1D8TgwgIbumHddJa9h4yl4GkKb8/bgAl2+g7eDgA==", "dev": true, "dependencies": { - "playwright": "1.40.0" + "playwright": "1.43.1" }, "bin": { "playwright": "cli.js" @@ -4732,12 +4745,12 @@ } }, "node_modules/@playwright/test/node_modules/playwright": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", - "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", + "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", "dev": true, "dependencies": { - "playwright-core": "1.40.0" + "playwright-core": "1.43.1" }, "bin": { "playwright": "cli.js" @@ -4750,9 +4763,9 @@ } }, "node_modules/@playwright/test/node_modules/playwright-core": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", - "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", + "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -17662,7 +17675,7 @@ "node_modules/playwright": { "version": "1.43.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", - "integrity": "sha512-XOsfl5ZtAik/T9oek4V0jAypNlaCNzuKOwVhqhgYT3os6kH34PzbRb74F0VWcLYa5WFdnmxl7qyAHBXvPv7lqQ==", + "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", "dependencies": { "playwright-core": "1.43.0" }, @@ -17676,6 +17689,17 @@ "fsevents": "2.3.2" } }, + "node_modules/playwright-core": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", + "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/playwright/node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -17689,17 +17713,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/playwright/node_modules/playwright-core": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", - "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -24980,19 +24993,6 @@ "@types/luxon": "^3.3.1" } }, - "packages/lib/node_modules/@playwright/browser-chromium": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.43.0.tgz", - "integrity": "sha512-TaHfh3rDsz4+tVKdMMo4kdFOk8/4U6cPyMXHhoiJVmhOhjHXjR0qPMoa5gz5jDGl478cn5SoXmtgKPgTDFuS0g==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "playwright-core": "1.43.0" - }, - "engines": { - "node": ">=16" - } - }, "packages/lib/node_modules/nanoid": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", @@ -25010,18 +25010,6 @@ "node": "^14 || ^16 || >=18" } }, - "packages/lib/node_modules/playwright-core": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", - "integrity": "sha512-UGKASUhXmvqm2Lxa1fNr8sFwAtqjpgBRr9jQ7XBI8Rn5uFiEowGUGwrruUQsVPIom4bk7Lt+oLGpXobnXzrBIw==", - "dev": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "packages/prettier-config": { "name": "@documenso/prettier-config", "version": "0.0.0", From 20edee7f1a2293344827f20deee372381aa25021 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 26 Apr 2024 16:01:09 +0700 Subject: [PATCH 27/27] 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 --- packages/lib/universal/get-feature-flag.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/lib/universal/get-feature-flag.ts b/packages/lib/universal/get-feature-flag.ts index f4650f691..92f186ab3 100644 --- a/packages/lib/universal/get-feature-flag.ts +++ b/packages/lib/universal/get-feature-flag.ts @@ -17,6 +17,7 @@ export const getFlag = async ( options?: GetFlagOptions, ): Promise => { const requestHeaders = options?.requestHeaders ?? {}; + delete requestHeaders['content-length']; if (!isFeatureFlagEnabled()) { 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`); url.searchParams.set('flag', flag); - const response = await fetch(url, { + return await fetch(url, { headers: { ...requestHeaders, }, @@ -35,9 +36,10 @@ export const getFlag = async ( }) .then(async (res) => res.json()) .then((res) => ZFeatureFlagValueSchema.parse(res)) - .catch(() => false); - - return response; + .catch((err) => { + console.error(err); + return LOCAL_FEATURE_FLAGS[flag] ?? false; + }); }; /** @@ -50,6 +52,7 @@ export const getAllFlags = async ( options?: GetFlagOptions, ): Promise> => { const requestHeaders = options?.requestHeaders ?? {}; + delete requestHeaders['content-length']; if (!isFeatureFlagEnabled()) { return LOCAL_FEATURE_FLAGS; @@ -67,7 +70,10 @@ export const getAllFlags = async ( }) .then(async (res) => res.json()) .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 res.json()) .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res)) - .catch(() => LOCAL_FEATURE_FLAGS); + .catch((err) => { + console.error(err); + return LOCAL_FEATURE_FLAGS; + }); }; interface GetFlagOptions {