diff --git a/apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx b/apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx new file mode 100644 index 000000000..0c6356f9a --- /dev/null +++ b/apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -0,0 +1,73 @@ +import { Trans } from '@lingui/react/macro'; + +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; + +import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure'; + +type ConfirmationDialogProps = { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + hasUninsertedFields: boolean; + isSubmitting: boolean; +}; + +export function AssistantConfirmationDialog({ + isOpen, + onClose, + onConfirm, + hasUninsertedFields, + isSubmitting, +}: ConfirmationDialogProps) { + const onOpenChange = () => { + if (isSubmitting) { + return; + } + + onClose(); + }; + + return ( + + + + + Complete Document + + + + Are you sure you want to complete the document? This action cannot be undone. Please + ensure that you have completed prefilling all relevant fields before proceeding. + + + + +
+ +
+ + + + + +
+
+ ); +} diff --git a/apps/remix/app/components/dialogs/document-move-dialog.tsx b/apps/remix/app/components/dialogs/document-move-dialog.tsx index afde845fa..1e0632531 100644 --- a/apps/remix/app/components/dialogs/document-move-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-move-dialog.tsx @@ -37,7 +37,7 @@ export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentM const [selectedTeamId, setSelectedTeamId] = useState(null); - const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery(); + const { data: teams, isPending: isLoadingTeams } = trpc.team.getTeams.useQuery(); const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({ onSuccess: () => { diff --git a/apps/remix/app/components/dialogs/team-transfer-dialog.tsx b/apps/remix/app/components/dialogs/team-transfer-dialog.tsx index 8cc5cf1f1..4e46233cc 100644 --- a/apps/remix/app/components/dialogs/team-transfer-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-transfer-dialog.tsx @@ -65,7 +65,7 @@ export const TeamTransferDialog = ({ const { data, refetch: refetchTeamMembers, - isLoading: loadingTeamMembers, + isPending: loadingTeamMembers, isLoadingError: loadingTeamMembersError, } = trpc.team.getTeamMembers.useQuery({ teamId, diff --git a/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx index 3c1bd9c8a..8142eb848 100644 --- a/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-direct-link-dialog.tsx @@ -76,7 +76,11 @@ export const TemplateDirectLinkDialog = ({ ); const validDirectTemplateRecipients = useMemo( - () => template.recipients.filter((recipient) => recipient.role !== RecipientRole.CC), + () => + template.recipients.filter( + (recipient) => + recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT, + ), [template.recipients], ); diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 0da38634c..47832c7af 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -483,7 +483,6 @@ export const EmbedDirectTemplateClientPage = ({ {/* Fields */} Promise | void; @@ -39,7 +38,6 @@ export type EmbedDocumentFieldsProps = { }; export const EmbedDocumentFields = ({ - recipient, fields, metadata, onSignField, @@ -53,7 +51,6 @@ export const EmbedDocumentFields = ({ @@ -72,7 +68,6 @@ export const EmbedDocumentFields = ({ @@ -81,7 +76,6 @@ export const EmbedDocumentFields = ({ @@ -107,7 +100,6 @@ export const EmbedDocumentFields = ({ @@ -123,7 +115,6 @@ export const EmbedDocumentFields = ({ @@ -139,7 +130,6 @@ export const EmbedDocumentFields = ({ @@ -155,7 +145,6 @@ export const EmbedDocumentFields = ({ @@ -171,7 +160,6 @@ export const EmbedDocumentFields = ({ diff --git a/apps/remix/app/components/embed/embed-document-signing-page.tsx b/apps/remix/app/components/embed/embed-document-signing-page.tsx index 9b6abbd4a..e566a0881 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page.tsx @@ -1,15 +1,16 @@ -import { useEffect, useLayoutEffect, useState } from 'react'; +import { useEffect, useId, useLayoutEffect, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { DocumentMeta, Recipient, TemplateMeta } from '@prisma/client'; -import { type DocumentData, type Field, FieldType } from '@prisma/client'; +import type { DocumentMeta, TemplateMeta } from '@prisma/client'; +import { type DocumentData, type Field, FieldType, RecipientRole } from '@prisma/client'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { validateFieldsInserted } from '@documenso/lib/utils/fields'; +import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import { trpc } from '@documenso/trpc/react'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { Button } from '@documenso/ui/primitives/button'; @@ -18,6 +19,7 @@ import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; +import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useToast } from '@documenso/ui/primitives/use-toast'; @@ -26,6 +28,7 @@ import { injectCss } from '~/utils/css-vars'; import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; +import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider'; import { EmbedClientLoading } from './embed-client-loading'; import { EmbedDocumentCompleted } from './embed-document-completed'; import { EmbedDocumentFields } from './embed-document-fields'; @@ -34,12 +37,13 @@ export type EmbedSignDocumentClientPageProps = { token: string; documentId: number; documentData: DocumentData; - recipient: Recipient; + recipient: RecipientWithFields; fields: Field[]; metadata?: DocumentMeta | TemplateMeta | null; isCompleted?: boolean; hidePoweredBy?: boolean; isPlatformOrEnterprise?: boolean; + allRecipients?: RecipientWithFields[]; }; export const EmbedSignDocumentClientPage = ({ @@ -52,6 +56,7 @@ export const EmbedSignDocumentClientPage = ({ isCompleted, hidePoweredBy = false, isPlatformOrEnterprise = false, + allRecipients = [], }: EmbedSignDocumentClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -69,17 +74,21 @@ export const EmbedSignDocumentClientPage = ({ const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted); + const [selectedSignerId, setSelectedSignerId] = useState( + allRecipients.length > 0 ? allRecipients[0].id : null, + ); const [isExpanded, setIsExpanded] = useState(false); - const [isNameLocked, setIsNameLocked] = useState(false); - const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); + const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId); + const isAssistantMode = recipient.role === RecipientRole.ASSISTANT; + const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500); const [pendingFields, _completedFields] = [ - fields.filter((field) => !field.inserted), + fields.filter((field) => field.recipientId === recipient.id && !field.inserted), fields.filter((field) => field.inserted), ]; @@ -88,6 +97,8 @@ export const EmbedSignDocumentClientPage = ({ const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); + const assistantSignersId = useId(); + const onNextFieldClick = () => { validateFieldsInserted(fields); @@ -213,164 +224,234 @@ export const EmbedSignDocumentClientPage = ({ } return ( -
- {(!hasFinishedInit || !hasDocumentLoaded) && } + +
+ {(!hasFinishedInit || !hasDocumentLoaded) && } -
- {/* Viewer */} -
- setHasDocumentLoaded(true)} - /> -
+
+ {/* Viewer */} +
+ setHasDocumentLoaded(true)} + /> +
- {/* Widget */} -
-
- {/* Header */} -
-
-

- Sign document -

+ {/* Widget */} +
+
+ {/* Header */} +
+
+

+ {isAssistantMode ? ( + Assist with signing + ) : ( + Sign document + )} +

- -
-
- -
-

- Sign the document to complete the process. -

- -
-
- - {/* Form */} -
-
-
- - - !isNameLocked && setFullName(e.target.value)} - /> -
- -
- - - -
- -
- - - - - { - setSignature(value); - }} - onValidityChange={(isValid) => { - setSignatureValid(isValid); - }} - allowTypedSignature={Boolean( - metadata && - 'typedSignatureEnabled' in metadata && - metadata.typedSignatureEnabled, - )} + +
+
- {hasSignatureField && !signatureValid && ( -
- - Signature is too small. Please provide a more complete signature. - +
+

+ {isAssistantMode ? ( + Help complete the document for other signers. + ) : ( + Sign the document to complete the process. + )} +

+ +
+
+ + {/* Form */} +
+
+ {isAssistantMode && ( +
+ + +
+ setSelectedSignerId(Number(value))} + > + {allRecipients + .filter((r) => r.fields.length > 0) + .map((r) => ( +
+
+
+ + +
+ +

{r.email}

+
+
+
+ {r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'} +
+
+
+ ))} +
+
)} + + {!isAssistantMode && ( + <> +
+ + + !isNameLocked && setFullName(e.target.value)} + /> +
+ +
+ + + +
+ +
+ + + + + { + setSignature(value); + }} + onValidityChange={(isValid) => { + setSignatureValid(isValid); + }} + allowTypedSignature={Boolean( + metadata && + 'typedSignatureEnabled' in metadata && + metadata.typedSignatureEnabled, + )} + /> + + + + {hasSignatureField && !signatureValid && ( +
+ + Signature is too small. Please provide a more complete signature. + +
+ )} +
+ + )}
-
-
+
-
- {pendingFields.length > 0 ? ( - - ) : ( - - )} +
+ {pendingFields.length > 0 ? ( + + ) : ( + + )} +
+ + + {showPendingFieldTooltip && pendingFields.length > 0 && ( + + Click to insert field + + )} + + + {/* Fields */} +
- - {showPendingFieldTooltip && pendingFields.length > 0 && ( - - Click to insert field - - )} - - - {/* Fields */} - + {!hidePoweredBy && ( +
+ Powered by + +
+ )}
- - {!hidePoweredBy && ( -
- Powered by - -
- )} -
+ ); }; diff --git a/apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx b/apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx new file mode 100644 index 000000000..a6c2bb199 --- /dev/null +++ b/apps/remix/app/components/embed/embed-document-waiting-for-turn.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; + +import { Trans } from '@lingui/react/macro'; + +export const EmbedDocumentWaitingForTurn = () => { + const [hasPostedMessage, setHasPostedMessage] = useState(false); + + useEffect(() => { + if (window.parent && !hasPostedMessage) { + window.parent.postMessage( + { + action: 'document-waiting-for-turn', + data: null, + }, + '*', + ); + } + + setHasPostedMessage(true); + }, [hasPostedMessage]); + + if (!hasPostedMessage) { + return null; + } + + return ( +
+

+ Waiting for Your Turn +

+ +
+

+ + It's currently not your turn to sign. Please check back soon as this document should be + available for you to sign shortly. + +

+ +

+ Please check with the parent application for more information. +

+
+
+ ); +}; diff --git a/apps/remix/app/components/general/app-command-menu.tsx b/apps/remix/app/components/general/app-command-menu.tsx index ed042d2ea..d6dcfe395 100644 --- a/apps/remix/app/components/general/app-command-menu.tsx +++ b/apps/remix/app/components/general/app-command-menu.tsx @@ -80,7 +80,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) { const [search, setSearch] = useState(''); const [pages, setPages] = useState([]); - const { data: searchDocumentsData, isLoading: isSearchingDocuments } = + const { data: searchDocumentsData, isPending: isSearchingDocuments } = trpcReact.document.searchDocuments.useQuery( { query: search, diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 8a1df7a5e..46bbcb1a8 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -51,6 +51,8 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; +import { DocumentSigningRecipientProvider } from '../document-signing/document-signing-recipient-provider'; + export type DirectTemplateSigningFormProps = { flowStep: DocumentFlowStep; directRecipient: Recipient; @@ -169,7 +171,7 @@ export const DirectTemplateSigningForm = ({ }; return ( - <> + @@ -186,7 +188,6 @@ export const DirectTemplateSigningForm = ({ @@ -195,7 +196,6 @@ export const DirectTemplateSigningForm = ({ @@ -204,7 +204,6 @@ export const DirectTemplateSigningForm = ({ @@ -213,7 +212,6 @@ export const DirectTemplateSigningForm = ({ @@ -241,7 +238,6 @@ export const DirectTemplateSigningForm = ({ ...field, fieldMeta: parsedFieldMeta, }} - recipient={directRecipient} onSignField={onSignField} onUnsignField={onUnsignField} /> @@ -259,7 +255,6 @@ export const DirectTemplateSigningForm = ({ ...field, fieldMeta: parsedFieldMeta, }} - recipient={directRecipient} onSignField={onSignField} onUnsignField={onUnsignField} /> @@ -277,7 +272,6 @@ export const DirectTemplateSigningForm = ({ ...field, fieldMeta: parsedFieldMeta, }} - recipient={directRecipient} onSignField={onSignField} onUnsignField={onUnsignField} /> @@ -295,7 +289,6 @@ export const DirectTemplateSigningForm = ({ ...field, fieldMeta: parsedFieldMeta, }} - recipient={directRecipient} onSignField={onSignField} onUnsignField={onUnsignField} /> @@ -313,7 +306,6 @@ export const DirectTemplateSigningForm = ({ ...field, fieldMeta: parsedFieldMeta, }} - recipient={directRecipient} onSignField={onSignField} onUnsignField={onUnsignField} /> @@ -383,6 +375,6 @@ export const DirectTemplateSigningForm = ({ />
- + ); }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx index 40ee65432..cd6cc7a10 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-checkbox-field.tsx @@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import type { Recipient } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useRevalidator } from 'react-router'; @@ -25,17 +24,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; export type DocumentSigningCheckboxFieldProps = { field: FieldWithSignatureAndFieldMeta; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; export const DocumentSigningCheckboxField = ({ field, - recipient, onSignField, onUnsignField, }: DocumentSigningCheckboxFieldProps) => { @@ -43,6 +41,8 @@ export const DocumentSigningCheckboxField = ({ const { toast } = useToast(); const { revalidate } = useRevalidator(); + const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta); @@ -118,7 +118,9 @@ export const DocumentSigningCheckboxField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -147,7 +149,7 @@ export const DocumentSigningCheckboxField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } diff --git a/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx index 255c07849..09a816b5b 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-date-field.tsx @@ -1,7 +1,6 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Recipient } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useRevalidator } from 'react-router'; @@ -24,10 +23,10 @@ import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; export type DocumentSigningDateFieldProps = { field: FieldWithSignature; - recipient: Recipient; dateFormat?: string | null; timezone?: string | null; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; @@ -36,7 +35,6 @@ export type DocumentSigningDateFieldProps = { export const DocumentSigningDateField = ({ field, - recipient, dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT, timezone = DEFAULT_DOCUMENT_TIME_ZONE, onSignField, @@ -46,6 +44,8 @@ export const DocumentSigningDateField = ({ const { toast } = useToast(); const { revalidate } = useRevalidator(); + const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); + const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); @@ -60,9 +60,7 @@ export const DocumentSigningDateField = ({ const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone); - const isDifferentTime = field.inserted && localDateString !== field.customText; - const tooltipText = _( msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone || ''}".`, ); @@ -95,7 +93,9 @@ export const DocumentSigningDateField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -121,7 +121,7 @@ export const DocumentSigningDateField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } diff --git a/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx index b34976a79..b2d5a4b0f 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-dropdown-field.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import type { Recipient } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useRevalidator } from 'react-router'; @@ -28,17 +27,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; export type DocumentSigningDropdownFieldProps = { field: FieldWithSignatureAndFieldMeta; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; export const DocumentSigningDropdownField = ({ field, - recipient, onSignField, onUnsignField, }: DocumentSigningDropdownFieldProps) => { @@ -46,6 +44,8 @@ export const DocumentSigningDropdownField = ({ const { toast } = useToast(); const { revalidate } = useRevalidator(); + const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const parsedFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta); @@ -99,7 +99,9 @@ export const DocumentSigningDropdownField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -131,7 +133,7 @@ export const DocumentSigningDropdownField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } diff --git a/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx index bf943f178..a7ebc1dbe 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-email-field.tsx @@ -1,7 +1,6 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Recipient } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useRevalidator } from 'react-router'; @@ -20,17 +19,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; import { useRequiredDocumentSigningContext } from './document-signing-provider'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; export type DocumentSigningEmailFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; export const DocumentSigningEmailField = ({ field, - recipient, onSignField, onUnsignField, }: DocumentSigningEmailFieldProps) => { @@ -40,6 +38,8 @@ export const DocumentSigningEmailField = ({ const { email: providedEmail } = useRequiredDocumentSigningContext(); + const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext(); + const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); @@ -84,7 +84,9 @@ export const DocumentSigningEmailField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -110,7 +112,7 @@ export const DocumentSigningEmailField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } diff --git a/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx index 111c16c6f..148117b55 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx @@ -44,6 +44,7 @@ export type DocumentSigningFieldContainerProps = { | 'Email' | 'Name' | 'Signature' + | 'Text' | 'Radio' | 'Dropdown' | 'Number' diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index 4e971b7b4..e98c21382 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -1,8 +1,10 @@ -import { useMemo, useState } from 'react'; +import { useId, useMemo, useState } from 'react'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; @@ -11,6 +13,7 @@ import type { DocumentAndSender } from '@documenso/lib/server-only/document/get- import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; +import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import { trpc } from '@documenso/trpc/react'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { cn } from '@documenso/ui/lib/utils'; @@ -18,8 +21,11 @@ import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; +import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { useRequiredDocumentSigningContext } from './document-signing-provider'; @@ -29,6 +35,8 @@ export type DocumentSigningFormProps = { fields: Field[]; redirectUrl?: string | null; isRecipientsTurn: boolean; + allRecipients?: RecipientWithFields[]; + setSelectedSignerId?: (id: number | null) => void; }; export const DocumentSigningForm = ({ @@ -37,20 +45,35 @@ export const DocumentSigningForm = ({ fields, redirectUrl, isRecipientsTurn, + allRecipients = [], + setSelectedSignerId, }: DocumentSigningFormProps) => { + const { user } = useOptionalSession(); + + const { _ } = useLingui(); + const { toast } = useToast(); + const navigate = useNavigate(); const analytics = useAnalytics(); - const { user } = useOptionalSession(); + const assistantSignersId = useId(); const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } = useRequiredDocumentSigningContext(); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); + const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); + const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false); const { mutateAsync: completeDocumentWithToken } = trpc.recipient.completeDocumentWithToken.useMutation(); + const assistantForm = useForm<{ selectedSignerId: number | undefined }>({ + defaultValues: { + selectedSignerId: undefined, + }, + }); + const { handleSubmit, formState } = useForm(); // Keep the loading state going if successful since the redirect may take some time. @@ -65,7 +88,11 @@ export const DocumentSigningForm = ({ const uninsertedFields = useMemo(() => { return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted)); - }, [fields]); + }, [fieldsRequiringValidation]); + + const uninsertedRecipientFields = useMemo(() => { + return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id); + }, [fieldsRequiringValidation, recipient]); const fieldsValidated = () => { setValidateUninsertedFields(true); @@ -86,12 +113,31 @@ export const DocumentSigningForm = ({ } await completeDocument(); + }; - // Reauth is currently not required for completing the document. - // await executeActionAuthProcedure({ - // onReauthFormSubmit: completeDocument, - // actionTarget: 'DOCUMENT', - // }); + const onAssistantFormSubmit = () => { + if (uninsertedRecipientFields.length > 0) { + return; + } + + setIsConfirmationDialogOpen(true); + }; + + const handleAssistantConfirmDialogSubmit = async () => { + setIsAssistantSubmitting(true); + + try { + await completeDocument(); + } catch (err) { + toast({ + title: 'Error', + description: 'An error occurred while completing the document. Please try again.', + variant: 'destructive', + }); + + setIsAssistantSubmitting(false); + setIsConfirmationDialogOpen(false); + } }; const completeDocument = async (authOptions?: TRecipientActionAuth) => { @@ -115,7 +161,7 @@ export const DocumentSigningForm = ({ }; return ( -
{validateUninsertedFields && uninsertedFields[0] && ( @@ -131,17 +176,13 @@ export const DocumentSigningForm = ({ )} -
-
+
+

{recipient.role === RecipientRole.VIEWER && View Document} {recipient.role === RecipientRole.SIGNER && Sign Document} {recipient.role === RecipientRole.APPROVER && Approve Document} + {recipient.role === RecipientRole.ASSISTANT && Assist Document}

{recipient.role === RecipientRole.VIEWER ? ( @@ -178,91 +219,185 @@ export const DocumentSigningForm = ({
+ ) : recipient.role === RecipientRole.ASSISTANT ? ( + <> + +

+ + Complete the fields for the following signers. Once reviewed, they will inform + you if any modifications are needed. + +

+ +
+ +
+ ( + { + field.onChange(value); + setSelectedSignerId?.(Number(value)); + }} + > + {allRecipients + .filter((r) => r.fields.length > 0) + .map((r) => ( +
+
+
+ + +
+ +

{r.email}

+
+
+
+ {r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'} +
+
+
+ ))} +
+ )} + /> +
+ +
+ +
+ + 0} + isOpen={isConfirmationDialogOpen} + onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)} + onConfirm={handleAssistantConfirmDialogSubmit} + isSubmitting={isAssistantSubmitting} + /> + + ) : ( <> -

- Please review the document before signing. -

+
+

+ Please review the document before signing. +

-
+
-
-
-
- +
+
+
+ - setFullName(e.target.value.trimStart())} + setFullName(e.target.value.trimStart())} + /> +
+ +
+ + + + + { + setSignatureValid(isValid); + }} + onChange={(value) => { + if (signatureValid) { + setSignature(value); + } + }} + allowTypedSignature={document.documentMeta?.typedSignatureEnabled} + /> + + + + {hasSignatureField && !signatureValid && ( +
+ + Signature is too small. Please provide a more complete signature. + +
+ )} +
+
+ +
+ + +
- -
- - - - - { - setSignatureValid(isValid); - }} - onChange={(value) => { - if (signatureValid) { - setSignature(value); - } - }} - allowTypedSignature={document.documentMeta?.typedSignatureEnabled} - /> - - - - {hasSignatureField && !signatureValid && ( -
- - Signature is too small. Please provide a more complete signature. - -
- )} -
-
- -
- - - -
-
+
+ )}
- - +
+
); }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx index efe0c5cc6..532b0cc4b 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-initials-field.tsx @@ -1,7 +1,6 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Recipient } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useRevalidator } from 'react-router'; @@ -19,17 +18,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; import { useRequiredDocumentSigningContext } from './document-signing-provider'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; export type DocumentSigningInitialsFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; export const DocumentSigningInitialsField = ({ field, - recipient, onSignField, onUnsignField, }: DocumentSigningInitialsFieldProps) => { @@ -38,6 +36,8 @@ export const DocumentSigningInitialsField = ({ const { revalidate } = useRevalidator(); const { fullName } = useRequiredDocumentSigningContext(); + const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); + const initials = extractInitials(fullName); const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = @@ -81,7 +81,9 @@ export const DocumentSigningInitialsField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } diff --git a/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx index 5fea0f1aa..7c0246c97 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-name-field.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { type Recipient } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useRevalidator } from 'react-router'; @@ -27,17 +26,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; import { useRequiredDocumentSigningContext } from './document-signing-provider'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; export type DocumentSigningNameFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; export const DocumentSigningNameField = ({ field, - recipient, onSignField, onUnsignField, }: DocumentSigningNameFieldProps) => { @@ -48,6 +46,8 @@ export const DocumentSigningNameField = ({ const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredDocumentSigningContext(); + const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); + const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = @@ -67,7 +67,7 @@ export const DocumentSigningNameField = ({ const [localFullName, setLocalFullName] = useState(''); const onPreSign = () => { - if (!providedFullName) { + if (!providedFullName && !isAssistantMode) { setShowFullNameModal(true); return false; } @@ -90,9 +90,9 @@ export const DocumentSigningNameField = ({ const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => { try { - const value = name || providedFullName; + const value = name || providedFullName || ''; - if (!value) { + if (!value && !isAssistantMode) { setShowFullNameModal(true); return; } @@ -124,7 +124,9 @@ export const DocumentSigningNameField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -150,7 +152,7 @@ export const DocumentSigningNameField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } diff --git a/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx index 8d293599e..5e015a215 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-number-field.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import type { Recipient } from '@prisma/client'; import { Hash, Loader } from 'lucide-react'; import { useRevalidator } from 'react-router'; @@ -26,6 +25,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; type ValidationErrors = { isNumber: string[]; @@ -37,14 +37,12 @@ type ValidationErrors = { export type DocumentSigningNumberFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; export const DocumentSigningNumberField = ({ field, - recipient, onSignField, onUnsignField, }: DocumentSigningNumberFieldProps) => { @@ -52,7 +50,9 @@ export const DocumentSigningNumberField = ({ const { toast } = useToast(); const { revalidate } = useRevalidator(); - const [showRadioModal, setShowRadioModal] = useState(false); + const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext(); + + const [showNumberModal, setShowNumberModal] = useState(false); const safeFieldMeta = ZNumberFieldMeta.safeParse(field.fieldMeta); const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null; @@ -107,7 +107,7 @@ export const DocumentSigningNumberField = ({ }; const onDialogSignClick = () => { - setShowRadioModal(false); + setShowNumberModal(false); void executeActionAuthProcedure({ onReauthFormSubmit: async (authOptions) => await onSign(authOptions), @@ -150,14 +150,20 @@ export const DocumentSigningNumberField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } }; const onPreSign = () => { - setShowRadioModal(true); + if (isAssistantMode) { + return true; + } + + setShowNumberModal(true); if (localNumber && parsedFieldMeta) { const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true); @@ -175,8 +181,14 @@ export const DocumentSigningNumberField = ({ const onRemove = async () => { try { + if (isAssistantMode && !targetSigner) { + return; + } + + const signingRecipient = isAssistantMode && targetSigner ? targetSigner : recipient; + const payload: TRemovedSignedFieldWithTokenMutationSchema = { - token: recipient.token, + token: signingRecipient.token, fieldId: field.id, }; @@ -195,18 +207,18 @@ export const DocumentSigningNumberField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } }; useEffect(() => { - if (!showRadioModal) { + if (!showNumberModal) { setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0'); setErrors(initialErrors); } - }, [showRadioModal]); + }, [showNumberModal]); useEffect(() => { if ( @@ -237,7 +249,7 @@ export const DocumentSigningNumberField = ({ onPreSign={onPreSign} onSign={onSign} onRemove={onRemove} - type="Signature" + type="Number" > {isLoading && (
@@ -280,7 +292,7 @@ export const DocumentSigningNumberField = ({
)} - + {parsedFieldMeta?.label ? parsedFieldMeta?.label : Number} @@ -336,7 +348,7 @@ export const DocumentSigningNumberField = ({ className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10" variant="secondary" onClick={() => { - setShowRadioModal(false); + setShowNumberModal(false); setLocalNumber(''); }} > diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx index 1057311b9..2ef032e6f 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react'; + import { Trans } from '@lingui/react/macro'; -import type { Field, Recipient } from '@prisma/client'; +import type { Field } from '@prisma/client'; import { FieldType, RecipientRole } from '@prisma/client'; import { match } from 'ts-pattern'; @@ -16,6 +18,7 @@ import { } from '@documenso/lib/types/field-meta'; import type { CompletedField } from '@documenso/lib/types/fields'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; +import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; 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'; @@ -35,12 +38,15 @@ import { DocumentSigningSignatureField } from '~/components/general/document-sig import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields'; +import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider'; + export type SigningPageViewProps = { document: DocumentAndSender; - recipient: Recipient; + recipient: RecipientWithFields; fields: Field[]; completedFields: CompletedField[]; isRecipientsTurn: boolean; + allRecipients?: RecipientWithFields[]; }; export const DocumentSigningPageView = ({ @@ -49,9 +55,12 @@ export const DocumentSigningPageView = ({ fields, completedFields, isRecipientsTurn, + allRecipients = [], }: SigningPageViewProps) => { const { documentData, documentMeta } = document; + const [selectedSignerId, setSelectedSignerId] = useState(allRecipients?.[0]?.id); + const shouldUseTeamDetails = document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false; @@ -63,183 +72,170 @@ export const DocumentSigningPageView = ({ senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : ''; } + const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId); + return ( -
-

- {document.title} -

- -
-
- - {senderName} {senderEmail} - {' '} - - {match(recipient.role) - .with(RecipientRole.VIEWER, () => - document.teamId && !shouldUseTeamDetails ? ( - - on behalf of "{document.team?.name}" has invited you to view this document - - ) : ( - has invited you to view this document - ), - ) - .with(RecipientRole.SIGNER, () => - document.teamId && !shouldUseTeamDetails ? ( - - on behalf of "{document.team?.name}" has invited you to sign this document - - ) : ( - has invited you to sign this document - ), - ) - .with(RecipientRole.APPROVER, () => - document.teamId && !shouldUseTeamDetails ? ( - - on behalf of "{document.team?.name}" has invited you to approve this document - - ) : ( - has invited you to approve this document - ), - ) - .otherwise(() => null)} - -
- - -
- -
- +
+

- - - - + {document.title} +

-
- +
+
+ + {senderName} {senderEmail} + {' '} + + {match(recipient.role) + .with(RecipientRole.VIEWER, () => + document.teamId && !shouldUseTeamDetails ? ( + + on behalf of "{document.team?.name}" has invited you to view this document + + ) : ( + has invited you to view this document + ), + ) + .with(RecipientRole.SIGNER, () => + document.teamId && !shouldUseTeamDetails ? ( + + on behalf of "{document.team?.name}" has invited you to sign this document + + ) : ( + has invited you to sign this document + ), + ) + .with(RecipientRole.APPROVER, () => + document.teamId && !shouldUseTeamDetails ? ( + + on behalf of "{document.team?.name}" has invited you to approve this document + + ) : ( + has invited you to approve this document + ), + ) + .with(RecipientRole.ASSISTANT, () => + document.teamId && !shouldUseTeamDetails ? ( + + on behalf of "{document.team?.name}" has invited you to assist this document + + ) : ( + has invited you to assist this document + ), + ) + .otherwise(() => null)} + +
+ +
-
- - - - - - {fields.map((field) => - match(field.type) - .with(FieldType.SIGNATURE, () => ( - + + + - )) - .with(FieldType.INITIALS, () => ( - - )) - .with(FieldType.NAME, () => ( - - )) - .with(FieldType.DATE, () => ( - - )) - .with(FieldType.EMAIL, () => ( - - )) - .with(FieldType.TEXT, () => { - const fieldWithMeta: FieldWithSignatureAndFieldMeta = { - ...field, - fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null, - }; - return ( - - ); - }) - .with(FieldType.NUMBER, () => { - const fieldWithMeta: FieldWithSignatureAndFieldMeta = { - ...field, - fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null, - }; - return ( - - ); - }) - .with(FieldType.RADIO, () => { - const fieldWithMeta: FieldWithSignatureAndFieldMeta = { - ...field, - fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null, - }; - return ( - - ); - }) - .with(FieldType.CHECKBOX, () => { - const fieldWithMeta: FieldWithSignatureAndFieldMeta = { - ...field, - fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null, - }; - return ( - - ); - }) - .with(FieldType.DROPDOWN, () => { - const fieldWithMeta: FieldWithSignatureAndFieldMeta = { - ...field, - fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null, - }; - return ( - - ); - }) - .otherwise(() => null), + + + +
+ +
+
+ + + + {recipient.role !== RecipientRole.ASSISTANT && ( + )} - -
+ + + {fields + .filter( + (field) => + recipient.role !== RecipientRole.ASSISTANT || + field.recipientId === selectedSigner?.id, + ) + .map((field) => + match(field.type) + .with(FieldType.SIGNATURE, () => ( + + )) + .with(FieldType.INITIALS, () => ( + + )) + .with(FieldType.NAME, () => ( + + )) + .with(FieldType.DATE, () => ( + + )) + .with(FieldType.EMAIL, () => ( + + )) + .with(FieldType.TEXT, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.NUMBER, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.RADIO, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.CHECKBOX, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.DROPDOWN, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .otherwise(() => null), + )} + +
+ ); }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx index 61c2771c9..3ac4eafbf 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-radio-field.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import type { Recipient } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useRevalidator } from 'react-router'; @@ -22,17 +21,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; export type DocumentSigningRadioFieldProps = { field: FieldWithSignatureAndFieldMeta; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; export const DocumentSigningRadioField = ({ field, - recipient, onSignField, onUnsignField, }: DocumentSigningRadioFieldProps) => { @@ -40,6 +38,8 @@ export const DocumentSigningRadioField = ({ const { toast } = useToast(); const { revalidate } = useRevalidator(); + const { recipient, targetSigner, isAssistantMode } = useDocumentSigningRecipientContext(); + const parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta); const values = parsedFieldMeta.values?.map((item) => ({ ...item, @@ -68,16 +68,26 @@ export const DocumentSigningRadioField = ({ const onSign = async (authOptions?: TRecipientActionAuth) => { try { + if (isAssistantMode && !targetSigner) { + return; + } + if (!selectedOption) { return; } + const signingRecipient = isAssistantMode && targetSigner ? targetSigner : recipient; + const payload: TSignFieldWithTokenMutationSchema = { - token: recipient.token, + token: signingRecipient.token, fieldId: field.id, value: selectedOption, isBase64: true, authOptions, + ...(isAssistantMode && { + isAssistantPrefill: true, + assistantId: recipient.id, + }), }; if (onSignField) { @@ -100,7 +110,9 @@ export const DocumentSigningRadioField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -127,7 +139,7 @@ export const DocumentSigningRadioField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the signature.`), + description: _(msg`An error occurred while removing the selection.`), variant: 'destructive', }); } diff --git a/apps/remix/app/components/general/document-signing/document-signing-recipient-provider.tsx b/apps/remix/app/components/general/document-signing/document-signing-recipient-provider.tsx new file mode 100644 index 000000000..96a051d56 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-recipient-provider.tsx @@ -0,0 +1,67 @@ +import { type PropsWithChildren, createContext, useContext } from 'react'; + +import type { Recipient } from '@prisma/client'; + +import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; + +export interface DocumentSigningRecipientContextValue { + /** + * The recipient who is currently signing the document. + * In regular mode, this is the actual signer. + * In assistant mode, this is the recipient who is helping fill out the document. + */ + recipient: Recipient | RecipientWithFields; + + /** + * Only present in assistant mode. + * The recipient on whose behalf we're filling out the document. + */ + targetSigner: RecipientWithFields | null; + + /** + * Whether we're in assistant mode (one recipient filling out for another) + */ + isAssistantMode: boolean; +} + +const DocumentSigningRecipientContext = createContext( + null, +); + +export interface DocumentSigningRecipientProviderProps extends PropsWithChildren { + recipient: Recipient | RecipientWithFields; + targetSigner?: RecipientWithFields | null; +} + +export const DocumentSigningRecipientProvider = ({ + children, + recipient, + targetSigner = null, +}: DocumentSigningRecipientProviderProps) => { + // console.log({ + // recipient, + // targetSigner, + // isAssistantMode: !!targetSigner, + // }); + return ( + + {children} + + ); +}; + +export function useDocumentSigningRecipientContext() { + const context = useContext(DocumentSigningRecipientContext); + + if (!context) { + throw new Error('useDocumentSigningRecipientContext must be used within a RecipientProvider'); + } + + return context; +} diff --git a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx index 99d29c2d5..1b1f92dbd 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx @@ -3,7 +3,6 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; -import { type Recipient } from '@prisma/client'; import { Loader } from 'lucide-react'; import { useRevalidator } from 'react-router'; @@ -27,11 +26,11 @@ import { DocumentSigningDisclosure } from '~/components/general/document-signing import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; import { useRequiredDocumentSigningContext } from './document-signing-provider'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; export type DocumentSigningSignatureFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; typedSignatureEnabled?: boolean; @@ -39,7 +38,6 @@ export type DocumentSigningSignatureFieldProps = { export const DocumentSigningSignatureField = ({ field, - recipient, onSignField, onUnsignField, typedSignatureEnabled, @@ -48,6 +46,8 @@ export const DocumentSigningSignatureField = ({ const { toast } = useToast(); const { revalidate } = useRevalidator(); + const { recipient } = useDocumentSigningRecipientContext(); + const signatureRef = useRef(null); const containerRef = useRef(null); const [fontSize, setFontSize] = useState(2); diff --git a/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx index a1aa569fd..5b83acaf5 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-text-field.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from 'react'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Plural, Trans } from '@lingui/react/macro'; -import type { Recipient } from '@prisma/client'; import { Loader, Type } from 'lucide-react'; import { useRevalidator } from 'react-router'; @@ -26,17 +25,27 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningFieldContainer } from './document-signing-field-container'; +import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; export type DocumentSigningTextFieldProps = { field: FieldWithSignatureAndFieldMeta; - recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +type ValidationErrors = { + required: string[]; + characterLimit: string[]; +}; + +export type TextFieldProps = { + field: FieldWithSignatureAndFieldMeta; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; export const DocumentSigningTextField = ({ field, - recipient, onSignField, onUnsignField, }: DocumentSigningTextFieldProps) => { @@ -44,11 +53,12 @@ export const DocumentSigningTextField = ({ const { toast } = useToast(); const { revalidate } = useRevalidator(); - const initialErrors: Record = { + const { recipient, isAssistantMode } = useDocumentSigningRecipientContext(); + + const initialErrors: ValidationErrors = { required: [], characterLimit: [], }; - const [errors, setErrors] = useState(initialErrors); const userInputHasErrors = Object.values(errors).some((error) => error.length > 0); @@ -166,7 +176,9 @@ export const DocumentSigningTextField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while signing the document.`), + description: isAssistantMode + ? _(msg`An error occurred while signing as assistant.`) + : _(msg`An error occurred while signing the document.`), variant: 'destructive', }); } @@ -194,7 +206,7 @@ export const DocumentSigningTextField = ({ toast({ title: _(msg`Error`), - description: _(msg`An error occurred while removing the text.`), + description: _(msg`An error occurred while removing the field.`), variant: 'destructive', }); } @@ -234,7 +246,7 @@ export const DocumentSigningTextField = ({ onPreSign={onPreSign} onSign={onSign} onRemove={onRemove} - type="Signature" + type="Text" > {isLoading && (
diff --git a/apps/remix/app/components/general/document/document-history-sheet.tsx b/apps/remix/app/components/general/document/document-history-sheet.tsx index 557310ce0..ef73c1c8f 100644 --- a/apps/remix/app/components/general/document/document-history-sheet.tsx +++ b/apps/remix/app/components/general/document/document-history-sheet.tsx @@ -351,6 +351,16 @@ export const DocumentHistorySheet = ({ /> ), ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => ( + + )) .exhaustive()} {isUserDetailsVisible && ( diff --git a/apps/remix/app/components/general/document/document-page-view-recipients.tsx b/apps/remix/app/components/general/document/document-page-view-recipients.tsx index e74cb6e10..854b41e08 100644 --- a/apps/remix/app/components/general/document/document-page-view-recipients.tsx +++ b/apps/remix/app/components/general/document/document-page-view-recipients.tsx @@ -11,6 +11,7 @@ import { MailOpenIcon, PenIcon, PlusIcon, + UserIcon, } from 'lucide-react'; import { Link } from 'react-router'; import { match } from 'ts-pattern'; @@ -118,6 +119,12 @@ export const DocumentPageViewRecipients = ({ Viewed )) + .with(RecipientRole.ASSISTANT, () => ( + <> + + Assisted + + )) .exhaustive()} )} diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx index d5e81589d..c9f3a227c 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx @@ -1,5 +1,5 @@ import { Trans } from '@lingui/react/macro'; -import { DocumentStatus, SigningStatus } from '@prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { Clock8 } from 'lucide-react'; import { Link, redirect } from 'react-router'; import { getOptionalLoaderContext } from 'server/utils/get-loader-session'; @@ -14,6 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; +import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { SigningCard3D } from '@documenso/ui/components/signing-card'; @@ -37,14 +38,14 @@ export async function loader({ params }: Route.LoaderArgs) { const user = session?.user; - const [document, fields, recipient, completedFields] = await Promise.all([ + const [document, recipient, fields, completedFields] = await Promise.all([ getDocumentAndSenderByToken({ token, userId: user?.id, requireAccessAuth: false, }).catch(() => null), - getFieldsForToken({ token }), getRecipientByToken({ token }).catch(() => null), + getFieldsForToken({ token }), getCompletedFieldsForToken({ token }), ]); @@ -57,12 +58,21 @@ export async function loader({ params }: Route.LoaderArgs) { throw new Response('Not Found', { status: 404 }); } + const recipientWithFields = { ...recipient, fields }; + const isRecipientsTurn = await getIsRecipientsTurnToSign({ token }); if (!isRecipientsTurn) { throw redirect(`/sign/${token}/waiting`); } + const allRecipients = + recipient.role === RecipientRole.ASSISTANT + ? await getRecipientsForAssistant({ + token, + }) + : []; + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ documentAuth: document.authOptions, recipientAuth: recipient.authOptions, @@ -133,6 +143,8 @@ export async function loader({ params }: Route.LoaderArgs) { document, fields, recipient, + recipientWithFields, + allRecipients, completedFields, recipientSignature, isRecipientsTurn, @@ -153,8 +165,16 @@ export default function SigningPage() { ); } - const { document, fields, recipient, completedFields, recipientSignature, isRecipientsTurn } = - data; + const { + document, + fields, + recipient, + completedFields, + recipientSignature, + isRecipientsTurn, + allRecipients, + recipientWithFields, + } = data; if (document.deletedAt) { return ( @@ -218,11 +238,12 @@ export default function SigningPage() { user={user} > diff --git a/apps/remix/app/routes/embed+/_layout.tsx b/apps/remix/app/routes/embed+/_layout.tsx index ec2868a9e..f415d3062 100644 --- a/apps/remix/app/routes/embed+/_layout.tsx +++ b/apps/remix/app/routes/embed+/_layout.tsx @@ -1,6 +1,7 @@ import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router'; import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required'; +import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn'; import { EmbedPaywall } from '~/components/embed/embed-paywall'; import type { Route } from './+types/_layout'; @@ -36,6 +37,10 @@ export function ErrorBoundary() { if (error.status === 403 && error.data.type === 'embed-paywall') { return ; } + + if (error.status === 403 && error.data.type === 'embed-waiting-for-turn') { + return ; + } } return
Not Found
; diff --git a/apps/remix/app/routes/embed+/direct.$url.tsx b/apps/remix/app/routes/embed+/direct.$url.tsx index dec8b0584..d5ea213ee 100644 --- a/apps/remix/app/routes/embed+/direct.$url.tsx +++ b/apps/remix/app/routes/embed+/direct.$url.tsx @@ -13,6 +13,7 @@ import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page'; import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider'; import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider'; +import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider'; import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader'; import type { Route } from './+types/direct.$url'; @@ -129,16 +130,18 @@ export default function EmbedDirectTemplatePage() { recipient={recipient} user={user} > - + + + ); diff --git a/apps/remix/app/routes/embed+/sign.$url.tsx b/apps/remix/app/routes/embed+/sign.$url.tsx index ef11dcfef..a4041fa82 100644 --- a/apps/remix/app/routes/embed+/sign.$url.tsx +++ b/apps/remix/app/routes/embed+/sign.$url.tsx @@ -1,4 +1,4 @@ -import { DocumentStatus } from '@prisma/client'; +import { DocumentStatus, RecipientRole } from '@prisma/client'; import { data } from 'react-router'; import { getLoaderSession } from 'server/utils/get-loader-session'; import { match } from 'ts-pattern'; @@ -8,7 +8,9 @@ import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-p import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; +import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; @@ -89,6 +91,26 @@ export async function loader({ params }: Route.LoaderArgs) { ); } + const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token }); + + if (!isRecipientsTurnToSign) { + throw data( + { + type: 'embed-waiting-for-turn', + }, + { + status: 403, + }, + ); + } + + const allRecipients = + recipient.role === RecipientRole.ASSISTANT + ? await getRecipientsForAssistant({ + token, + }) + : []; + const team = document.teamId ? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null) : null; @@ -99,6 +121,7 @@ export async function loader({ params }: Route.LoaderArgs) { token, user, document, + allRecipients, recipient, fields, hidePoweredBy, @@ -112,6 +135,7 @@ export default function EmbedSignDocumentPage() { token, user, document, + allRecipients, recipient, fields, hidePoweredBy, @@ -140,6 +164,7 @@ export default function EmbedSignDocumentPage() { isCompleted={document.status === DocumentStatus.COMPLETED} hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy} isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument} + allRecipients={allRecipients} /> 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 938ec7265..e5a67f09f 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -533,12 +533,19 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip if (i > 1) { await page.getByRole('button', { name: 'Add Signer' }).click(); } + await page - .getByPlaceholder('Email') + .getByLabel('Email') + .nth(i - 1) + .focus(); + + await page + .getByLabel('Email') .nth(i - 1) .fill(`user${i}@example.com`); + await page - .getByPlaceholder('Name') + .getByLabel('Name') .nth(i - 1) .fill(`User ${i}`); } diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx index d0c0b0afa..f5d1a5407 100644 --- a/packages/email/template-components/template-document-invite.tsx +++ b/packages/email/template-components/template-document-invite.tsx @@ -84,6 +84,9 @@ export const TemplateDocumentInvite = ({ .with(RecipientRole.VIEWER, () => Continue by viewing the document.) .with(RecipientRole.APPROVER, () => Continue by approving the document.) .with(RecipientRole.CC, () => '') + .with(RecipientRole.ASSISTANT, () => ( + Continue by assisting with the document. + )) .exhaustive()} @@ -104,6 +107,7 @@ export const TemplateDocumentInvite = ({ .with(RecipientRole.VIEWER, () => View Document) .with(RecipientRole.APPROVER, () => Approve Document) .with(RecipientRole.CC, () => '') + .with(RecipientRole.ASSISTANT, () => Assist Document) .exhaustive()} diff --git a/packages/lib/constants/document-audit-logs.ts b/packages/lib/constants/document-audit-logs.ts index 8ae654977..9b91d2cb9 100644 --- a/packages/lib/constants/document-audit-logs.ts +++ b/packages/lib/constants/document-audit-logs.ts @@ -10,6 +10,9 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = { [DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: { description: 'Approval request', }, + [DOCUMENT_EMAIL_TYPE.ASSISTING_REQUEST]: { + description: 'Assisting request', + }, [DOCUMENT_EMAIL_TYPE.CC]: { description: 'CC', }, diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts index 2e30ec92b..f95390968 100644 --- a/packages/lib/constants/recipient-roles.ts +++ b/packages/lib/constants/recipient-roles.ts @@ -31,12 +31,26 @@ export const RECIPIENT_ROLES_DESCRIPTION = { roleName: msg`Viewer`, roleNamePlural: msg`Viewers`, }, + [RecipientRole.ASSISTANT]: { + actionVerb: msg`Assist`, + actioned: msg`Assisted`, + progressiveVerb: msg`Assisting`, + roleName: msg`Assistant`, + roleNamePlural: msg`Assistants`, + }, } satisfies Record; +export const RECIPIENT_ROLE_TO_DISPLAY_TYPE = { + [RecipientRole.SIGNER]: `SIGNING_REQUEST`, + [RecipientRole.VIEWER]: `VIEW_REQUEST`, + [RecipientRole.APPROVER]: `APPROVE_REQUEST`, +} as const; + export const RECIPIENT_ROLE_TO_EMAIL_TYPE = { [RecipientRole.SIGNER]: `SIGNING_REQUEST`, [RecipientRole.VIEWER]: `VIEW_REQUEST`, [RecipientRole.APPROVER]: `APPROVE_REQUEST`, + [RecipientRole.ASSISTANT]: `ASSISTING_REQUEST`, } as const; export const RECIPIENT_ROLE_SIGNING_REASONS = { @@ -44,4 +58,5 @@ export const RECIPIENT_ROLE_SIGNING_REASONS = { [RecipientRole.APPROVER]: msg`I am an approver of this document`, [RecipientRole.CC]: msg`I am required to receive a copy of this document`, [RecipientRole.VIEWER]: msg`I am a viewer of this document`, + [RecipientRole.ASSISTANT]: msg`I am an assistant of this document`, } satisfies Record; diff --git a/packages/lib/server-only/field/get-fields-for-token.ts b/packages/lib/server-only/field/get-fields-for-token.ts index 635773f8f..6abb07281 100644 --- a/packages/lib/server-only/field/get-fields-for-token.ts +++ b/packages/lib/server-only/field/get-fields-for-token.ts @@ -1,15 +1,55 @@ import { prisma } from '@documenso/prisma'; +import { FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client'; export type GetFieldsForTokenOptions = { token: string; }; export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => { + if (!token) { + throw new Error('Missing token'); + } + + const recipient = await prisma.recipient.findFirst({ + where: { token }, + }); + + if (!recipient) { + return []; + } + + if (recipient.role === RecipientRole.ASSISTANT) { + return await prisma.field.findMany({ + where: { + OR: [ + { + type: { + not: FieldType.SIGNATURE, + }, + recipient: { + signingStatus: { + not: SigningStatus.SIGNED, + }, + signingOrder: { + gte: recipient.signingOrder ?? 0, + }, + }, + documentId: recipient.documentId, + }, + { + recipientId: recipient.id, + }, + ], + }, + include: { + signature: true, + }, + }); + } + return await prisma.field.findMany({ where: { - recipient: { - token, - }, + recipientId: recipient.id, }, include: { signature: true, 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 39ea34e80..e95a8048d 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 @@ -1,4 +1,4 @@ -import { DocumentStatus, SigningStatus } from '@prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; @@ -16,11 +16,28 @@ export const removeSignedFieldWithToken = async ({ fieldId, requestMetadata, }: RemovedSignedFieldWithTokenOptions) => { + const recipient = await prisma.recipient.findFirstOrThrow({ + where: { + token, + }, + }); + const field = await prisma.field.findFirstOrThrow({ where: { id: fieldId, recipient: { - token, + ...(recipient.role !== RecipientRole.ASSISTANT + ? { + id: recipient.id, + } + : { + signingOrder: { + gte: recipient.signingOrder ?? 0, + }, + signingStatus: { + not: SigningStatus.SIGNED, + }, + }), }, }, include: { @@ -29,7 +46,7 @@ export const removeSignedFieldWithToken = async ({ }, }); - const { document, recipient } = field; + const { document } = field; if (!document) { throw new Error(`Document not found for field ${field.id}`); @@ -39,7 +56,10 @@ export const removeSignedFieldWithToken = async ({ throw new Error(`Document ${document.id} must be pending`); } - if (recipient?.signingStatus === SigningStatus.SIGNED) { + if ( + recipient?.signingStatus === SigningStatus.SIGNED || + field.recipient.signingStatus === SigningStatus.SIGNED + ) { throw new Error(`Recipient ${recipient.id} has already signed`); } @@ -65,20 +85,22 @@ export const removeSignedFieldWithToken = async ({ }, }); - await tx.documentAuditLog.create({ - data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED, - documentId: document.id, - user: { - name: recipient?.name, - email: recipient?.email, - }, - requestMetadata, - data: { - field: field.type, - fieldId: field.secondaryId, - }, - }), - }); + if (recipient.role !== RecipientRole.ASSISTANT) { + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED, + documentId: document.id, + user: { + name: recipient.name, + email: recipient.email, + }, + requestMetadata, + data: { + field: field.type, + fieldId: field.secondaryId, + }, + }), + }); + } }); }; 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 48dd6d1f3..22933ba75 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -1,4 +1,4 @@ -import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client'; +import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@prisma/client'; import { DateTime } from 'luxon'; import { match } from 'ts-pattern'; @@ -54,20 +54,41 @@ export const signFieldWithToken = async ({ authOptions, requestMetadata, }: SignFieldWithTokenOptions) => { + const recipient = await prisma.recipient.findFirstOrThrow({ + where: { + token, + }, + }); + const field = await prisma.field.findFirstOrThrow({ where: { id: fieldId, recipient: { - token, + ...(recipient.role !== RecipientRole.ASSISTANT + ? { + id: recipient.id, + } + : { + signingStatus: { + not: SigningStatus.SIGNED, + }, + signingOrder: { + gte: recipient.signingOrder ?? 0, + }, + }), }, }, include: { - document: true, + document: { + include: { + recipients: true, + }, + }, recipient: true, }, }); - const { document, recipient } = field; + const { document } = field; if (!document) { throw new Error(`Document not found for field ${field.id}`); @@ -85,7 +106,10 @@ export const signFieldWithToken = async ({ throw new Error(`Document ${document.id} must be pending for signing`); } - if (recipient?.signingStatus === SigningStatus.SIGNED) { + if ( + recipient.signingStatus === SigningStatus.SIGNED || + field.recipient.signingStatus === SigningStatus.SIGNED + ) { throw new Error(`Recipient ${recipient.id} has already signed`); } @@ -181,6 +205,8 @@ export const signFieldWithToken = async ({ throw new Error('Typed signatures are not allowed. Please draw your signature'); } + const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined; + return await prisma.$transaction(async (tx) => { const updatedField = await tx.field.update({ where: { @@ -217,11 +243,14 @@ export const signFieldWithToken = async ({ await tx.documentAuditLog.create({ data: createDocumentAuditLogData({ - type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, + type: + assistant && field.recipientId !== assistant.id + ? DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED + : DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED, documentId: document.id, user: { - email: recipient.email, - name: recipient.name, + email: assistant?.email ?? recipient.email, + name: assistant?.name ?? recipient.name, }, requestMetadata, data: { diff --git a/packages/lib/server-only/recipient/get-recipient-by-token.ts b/packages/lib/server-only/recipient/get-recipient-by-token.ts index d12151b41..d24a08603 100644 --- a/packages/lib/server-only/recipient/get-recipient-by-token.ts +++ b/packages/lib/server-only/recipient/get-recipient-by-token.ts @@ -9,5 +9,8 @@ export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions) where: { token, }, + include: { + fields: true, + }, }); }; diff --git a/packages/lib/server-only/recipient/get-recipients-for-assistant.ts b/packages/lib/server-only/recipient/get-recipients-for-assistant.ts new file mode 100644 index 000000000..6c15af639 --- /dev/null +++ b/packages/lib/server-only/recipient/get-recipients-for-assistant.ts @@ -0,0 +1,57 @@ +import { prisma } from '@documenso/prisma'; +import { FieldType } from '@documenso/prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; + +export interface GetRecipientsForAssistantOptions { + token: string; +} + +export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssistantOptions) => { + const assistant = await prisma.recipient.findFirst({ + where: { + token, + }, + }); + + if (!assistant) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Assistant not found', + }); + } + + let recipients = await prisma.recipient.findMany({ + where: { + documentId: assistant.documentId, + signingOrder: { + gte: assistant.signingOrder ?? 0, + }, + }, + include: { + fields: { + where: { + OR: [ + { + recipientId: assistant.id, + }, + { + type: { + not: FieldType.SIGNATURE, + }, + documentId: assistant.documentId, + }, + ], + }, + }, + }, + }); + + // Omit the token for recipients other than the assistant so + // it doesn't get sent to the client. + recipients = recipients.map((recipient) => ({ + ...recipient, + token: recipient.id === assistant.id ? token : '', + })); + + return recipients; +}; diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index 3d6f1d858..cb7873834 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -27,6 +27,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'DOCUMENT_DELETED', // When the document is soft deleted. 'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient. 'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient. + 'DOCUMENT_FIELD_PREFILLED', // When a field is prefilled by an assistant. 'DOCUMENT_VISIBILITY_UPDATED', // When the document visibility scope is updated 'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication is updated. 'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated. @@ -44,6 +45,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([ 'SIGNING_REQUEST', 'VIEW_REQUEST', 'APPROVE_REQUEST', + 'ASSISTING_REQUEST', 'CC', 'DOCUMENT_COMPLETED', ]); @@ -312,6 +314,83 @@ export const ZDocumentAuditLogEventDocumentFieldUninsertedSchema = z.object({ }), }); +/** + * Event: Document field prefilled by assistant. + */ +export const ZDocumentAuditLogEventDocumentFieldPrefilledSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED), + data: ZBaseRecipientDataSchema.extend({ + fieldId: z.string(), + + // Organised into union to allow us to extend each field if required. + field: z.union([ + z.object({ + type: z.literal(FieldType.INITIALS), + data: z.string(), + }), + z.object({ + type: z.literal(FieldType.EMAIL), + data: z.string(), + }), + z.object({ + type: z.literal(FieldType.DATE), + data: z.string(), + }), + z.object({ + type: z.literal(FieldType.NAME), + data: z.string(), + }), + z.object({ + type: z.literal(FieldType.TEXT), + data: z.string(), + }), + z.object({ + type: z.union([z.literal(FieldType.SIGNATURE), z.literal(FieldType.FREE_SIGNATURE)]), + data: z.string(), + }), + z.object({ + type: z.literal(FieldType.RADIO), + data: z.string(), + }), + z.object({ + type: z.literal(FieldType.CHECKBOX), + data: z.string(), + }), + z.object({ + type: z.literal(FieldType.DROPDOWN), + data: z.string(), + }), + z.object({ + type: z.literal(FieldType.NUMBER), + data: z.string(), + }), + ]), + fieldSecurity: z.preprocess( + (input) => { + const legacyNoneSecurityType = JSON.stringify({ + type: 'NONE', + }); + + // Replace legacy 'NONE' field security type with undefined. + if ( + typeof input === 'object' && + input !== null && + JSON.stringify(input) === legacyNoneSecurityType + ) { + return undefined; + } + + return input; + }, + z + .object({ + type: ZRecipientActionAuthTypesSchema, + }) + .optional(), + ), + }), +}); + export const ZDocumentAuditLogEventDocumentVisibilitySchema = z.object({ type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED), data: ZGenericFromToSchema, @@ -492,6 +571,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( ZDocumentAuditLogEventDocumentMovedToTeamSchema, ZDocumentAuditLogEventDocumentFieldInsertedSchema, ZDocumentAuditLogEventDocumentFieldUninsertedSchema, + ZDocumentAuditLogEventDocumentFieldPrefilledSchema, ZDocumentAuditLogEventDocumentVisibilitySchema, ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema, ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema, diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index 27278abd1..bc564370b 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -313,6 +313,10 @@ export const formatDocumentAuditLogAction = ( anonymous: msg`Field unsigned`, identified: msg`${prefix} unsigned a field`, })) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({ + anonymous: msg`Field prefilled by assistant`, + identified: msg`${prefix} prefilled a field`, + })) .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({ anonymous: msg`Document visibility updated`, identified: msg`${prefix} updated the document visibility`, diff --git a/packages/prisma/migrations/20250108133544_add_assistant_recipient_role/migration.sql b/packages/prisma/migrations/20250108133544_add_assistant_recipient_role/migration.sql new file mode 100644 index 000000000..b5eb3e491 --- /dev/null +++ b/packages/prisma/migrations/20250108133544_add_assistant_recipient_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "RecipientRole" ADD VALUE 'ASSISTANT'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index c80c0ad3e..4c012346a 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -425,6 +425,7 @@ enum RecipientRole { SIGNER VIEWER APPROVER + ASSISTANT } /// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"]) diff --git a/packages/prisma/types/recipient-with-fields.ts b/packages/prisma/types/recipient-with-fields.ts new file mode 100644 index 000000000..ed4314897 --- /dev/null +++ b/packages/prisma/types/recipient-with-fields.ts @@ -0,0 +1,5 @@ +import type { Field, Recipient } from '@documenso/prisma/client'; + +export type RecipientWithFields = Recipient & { + fields: Field[]; +}; diff --git a/packages/ui/components/recipient/recipient-role-select.tsx b/packages/ui/components/recipient/recipient-role-select.tsx index da0b0c097..0114a394e 100644 --- a/packages/ui/components/recipient/recipient-role-select.tsx +++ b/packages/ui/components/recipient/recipient-role-select.tsx @@ -9,12 +9,15 @@ import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons'; import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { cn } from '../../lib/utils'; + export type RecipientRoleSelectProps = SelectProps & { hideCCRecipients?: boolean; + isAssistantEnabled?: boolean; }; export const RecipientRoleSelect = forwardRef( - ({ hideCCRecipients, ...props }, ref) => ( + ({ hideCCRecipients, isAssistantEnabled = true, ...props }, ref) => ( ), diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 49653c516..39139e29b 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -498,7 +498,15 @@ export const AddFieldsFormPartial = ({ }, []); useEffect(() => { - setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]); + const recipientsByRoleToDisplay = recipients.filter( + (recipient) => + recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT, + ); + + setSelectedSigner( + recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ?? + recipientsByRoleToDisplay[0], + ); }, [recipients]); const recipientsByRole = useMemo(() => { @@ -507,6 +515,7 @@ export const AddFieldsFormPartial = ({ VIEWER: [], SIGNER: [], APPROVER: [], + ASSISTANT: [], }; recipients.forEach((recipient) => { @@ -519,7 +528,12 @@ export const AddFieldsFormPartial = ({ const recipientsByRoleToDisplay = useMemo(() => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]) - .filter(([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER) + .filter( + ([role]) => + role !== RecipientRole.CC && + role !== RecipientRole.VIEWER && + role !== RecipientRole.ASSISTANT, + ) .map( ([role, roleRecipients]) => // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -671,9 +685,7 @@ export const AddFieldsFormPartial = ({ )} {!selectedSigner?.email && ( - - {selectedSigner?.email} - + {selectedSigner?.email} )} diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index c277094c9..ce86cff42 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -40,6 +40,7 @@ import { DocumentFlowFormContainerStep, } from './document-flow-root'; import { ShowFieldItem } from './show-field-item'; +import { SigningOrderConfirmation } from './signing-order-confirmation'; import type { DocumentFlowStep } from './types'; export type AddSignersFormProps = { @@ -120,6 +121,7 @@ export const AddSignersFormPartial = ({ }, [recipients, form]); const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings); + const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false); const { setValue, @@ -131,6 +133,10 @@ export const AddSignersFormPartial = ({ const watchedSigners = watch('signers'); const isSigningOrderSequential = watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL; + const hasAssistantRole = useMemo(() => { + return watchedSigners.some((signer) => signer.role === RecipientRole.ASSISTANT); + }, [watchedSigners]); + const normalizeSigningOrders = (signers: typeof watchedSigners) => { return signers .sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0)) @@ -230,6 +236,7 @@ export const AddSignersFormPartial = ({ const items = Array.from(watchedSigners); const [reorderedSigner] = items.splice(result.source.index, 1); + // Find next valid position let insertIndex = result.destination.index; while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].nativeId)) { insertIndex++; @@ -237,126 +244,116 @@ export const AddSignersFormPartial = ({ items.splice(insertIndex, 0, reorderedSigner); - const updatedSigners = items.map((item, index) => ({ - ...item, - signingOrder: !canRecipientBeModified(item.nativeId) ? item.signingOrder : index + 1, + const updatedSigners = items.map((signer, index) => ({ + ...signer, + signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1, })); - updatedSigners.forEach((item, index) => { - const keys: (keyof typeof item)[] = [ - 'formId', - 'nativeId', - 'email', - 'name', - 'role', - 'signingOrder', - 'actionAuth', - ]; - keys.forEach((key) => { - form.setValue(`signers.${index}.${key}` as const, item[key]); - }); - }); + form.setValue('signers', updatedSigners); - const currentLength = form.getValues('signers').length; - if (currentLength > updatedSigners.length) { - for (let i = updatedSigners.length; i < currentLength; i++) { - form.unregister(`signers.${i}`); - } + const lastSigner = updatedSigners[updatedSigners.length - 1]; + if (lastSigner.role === RecipientRole.ASSISTANT) { + toast({ + title: _(msg`Warning: Assistant as last signer`), + description: _( + msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, + ), + }); } await form.trigger('signers'); }, - [form, canRecipientBeModified, watchedSigners], + [form, canRecipientBeModified, watchedSigners, toast], ); - const triggerDragAndDrop = useCallback( - (fromIndex: number, toIndex: number) => { - if (!$sensorApi.current) { + const handleRoleChange = useCallback( + (index: number, role: RecipientRole) => { + const currentSigners = form.getValues('signers'); + const signingOrder = form.getValues('signingOrder'); + + // Handle parallel to sequential conversion for assistants + if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { + form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL); + toast({ + title: _(msg`Signing order is enabled.`), + description: _(msg`You cannot add assistants when signing order is disabled.`), + variant: 'destructive', + }); return; } - const draggableId = signers[fromIndex].id; + const updatedSigners = currentSigners.map((signer, idx) => ({ + ...signer, + role: idx === index ? role : signer.role, + signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1, + })); - const preDrag = $sensorApi.current.tryGetLock(draggableId); + form.setValue('signers', updatedSigners); - if (!preDrag) { - return; + if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { + toast({ + title: _(msg`Warning: Assistant as last signer`), + description: _( + msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, + ), + }); } - - const drag = preDrag.snapLift(); - - setTimeout(() => { - // Move directly to the target index - if (fromIndex < toIndex) { - for (let i = fromIndex; i < toIndex; i++) { - drag.moveDown(); - } - } else { - for (let i = fromIndex; i > toIndex; i--) { - drag.moveUp(); - } - } - - setTimeout(() => { - drag.drop(); - }, 500); - }, 0); }, - [signers], - ); - - const updateSigningOrders = useCallback( - (newIndex: number, oldIndex: number) => { - const updatedSigners = form.getValues('signers').map((signer, index) => { - if (index === oldIndex) { - return { ...signer, signingOrder: newIndex + 1 }; - } else if (index >= newIndex && index < oldIndex) { - return { - ...signer, - signingOrder: !canRecipientBeModified(signer.nativeId) - ? signer.signingOrder - : (signer.signingOrder ?? index + 1) + 1, - }; - } else if (index <= newIndex && index > oldIndex) { - return { - ...signer, - signingOrder: !canRecipientBeModified(signer.nativeId) - ? signer.signingOrder - : Math.max(1, (signer.signingOrder ?? index + 1) - 1), - }; - } - return signer; - }); - - updatedSigners.forEach((signer, index) => { - form.setValue(`signers.${index}.signingOrder`, signer.signingOrder); - }); - }, - [form, canRecipientBeModified], + [form, toast, canRecipientBeModified], ); const handleSigningOrderChange = useCallback( (index: number, newOrderString: string) => { - const newOrder = parseInt(newOrderString, 10); - - if (!newOrderString.trim()) { + const trimmedOrderString = newOrderString.trim(); + if (!trimmedOrderString) { return; } - if (Number.isNaN(newOrder)) { - form.setValue(`signers.${index}.signingOrder`, index + 1); + const newOrder = Number(trimmedOrderString); + if (!Number.isInteger(newOrder) || newOrder < 1) { return; } - const newIndex = newOrder - 1; - if (index !== newIndex) { - updateSigningOrders(newIndex, index); - triggerDragAndDrop(index, newIndex); + const currentSigners = form.getValues('signers'); + const signer = currentSigners[index]; + + // Remove signer from current position and insert at new position + const remainingSigners = currentSigners.filter((_, idx) => idx !== index); + const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1); + remainingSigners.splice(newPosition, 0, signer); + + const updatedSigners = remainingSigners.map((s, idx) => ({ + ...s, + signingOrder: !canRecipientBeModified(s.nativeId) ? s.signingOrder : idx + 1, + })); + + form.setValue('signers', updatedSigners); + + if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) { + toast({ + title: _(msg`Warning: Assistant as last signer`), + description: _( + msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, + ), + }); } }, - [form, triggerDragAndDrop, updateSigningOrders], + [form, canRecipientBeModified, toast], ); + const handleSigningOrderDisable = useCallback(() => { + setShowSigningOrderConfirmation(false); + + const currentSigners = form.getValues('signers'); + const updatedSigners = currentSigners.map((signer) => ({ + ...signer, + role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, + })); + + form.setValue('signers', updatedSigners); + form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); + }, [form]); + return ( <> + onCheckedChange={(checked) => { + if (!checked && hasAssistantRole) { + setShowSigningOrderConfirmation(true); + return; + } + field.onChange( checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL, - ) - } + ); + }} disabled={isSubmitting || hasDocumentBeenSent} /> @@ -610,7 +612,11 @@ export const AddSignersFormPartial = ({ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + handleRoleChange(index, value as RecipientRole) + } disabled={ snapshot.isDragging || isSubmitting || @@ -707,6 +713,12 @@ export const AddSignersFormPartial = ({ )} + + diff --git a/packages/ui/primitives/document-flow/signing-order-confirmation.tsx b/packages/ui/primitives/document-flow/signing-order-confirmation.tsx new file mode 100644 index 000000000..e127ec484 --- /dev/null +++ b/packages/ui/primitives/document-flow/signing-order-confirmation.tsx @@ -0,0 +1,40 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@documenso/ui/primitives/alert-dialog'; + +export type SigningOrderConfirmationProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}; + +export function SigningOrderConfirmation({ + open, + onOpenChange, + onConfirm, +}: SigningOrderConfirmationProps) { + return ( + + + + Warning + + You have an assistant role on the signers list, removing the signing order will change + the assistant role to signer. + + + + Cancel + Proceed + + + + ); +} diff --git a/packages/ui/primitives/radio-group.tsx b/packages/ui/primitives/radio-group.tsx index 931d3aa40..cafb841e5 100644 --- a/packages/ui/primitives/radio-group.tsx +++ b/packages/ui/primitives/radio-group.tsx @@ -17,18 +17,18 @@ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; const RadioGroupItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, children: _children, ...props }, ref) => { +>(({ className, ...props }, ref) => { return ( - + ); diff --git a/packages/ui/primitives/recipient-role-icons.tsx b/packages/ui/primitives/recipient-role-icons.tsx index 4a139e59f..ab855e41f 100644 --- a/packages/ui/primitives/recipient-role-icons.tsx +++ b/packages/ui/primitives/recipient-role-icons.tsx @@ -1,9 +1,10 @@ import type { RecipientRole } from '@prisma/client'; -import { BadgeCheck, Copy, Eye, PencilLine } from 'lucide-react'; +import { BadgeCheck, Copy, Eye, PencilLine, User } from 'lucide-react'; export const ROLE_ICONS: Record = { SIGNER: , APPROVER: , CC: , VIEWER: , + ASSISTANT: , }; diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx index 5d1190e3a..88ae949f2 100644 --- a/packages/ui/primitives/template-flow/add-template-fields.tsx +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { Field, Recipient } from '@prisma/client'; -import { FieldType, RecipientRole } from '@prisma/client'; +import { FieldType, RecipientRole, SendStatus } from '@prisma/client'; import { CalendarDays, CheckSquare, @@ -428,6 +428,7 @@ export const AddTemplateFieldsFormPartial = ({ VIEWER: [], SIGNER: [], APPROVER: [], + ASSISTANT: [], }; recipients.forEach((recipient) => { @@ -437,10 +438,25 @@ export const AddTemplateFieldsFormPartial = ({ return recipientsByRole; }, [recipients]); + useEffect(() => { + const recipientsByRoleToDisplay = recipients.filter( + (recipient) => + recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT, + ); + + setSelectedSigner( + recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ?? + recipientsByRoleToDisplay[0], + ); + }, [recipients]); + const recipientsByRoleToDisplay = useMemo(() => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter( - ([role]) => role !== RecipientRole.CC && role !== RecipientRole.VIEWER, + ([role]) => + role !== RecipientRole.CC && + role !== RecipientRole.VIEWER && + role !== RecipientRole.ASSISTANT, ); }, [recipientsByRole]); 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 175984c14..b3709c587 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -23,6 +23,7 @@ import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import { Input } from '@documenso/ui/primitives/input'; +import { toast } from '@documenso/ui/primitives/use-toast'; import { Checkbox } from '../checkbox'; import { @@ -33,6 +34,7 @@ import { DocumentFlowFormContainerStep, } from '../document-flow/document-flow-root'; import { ShowFieldItem } from '../document-flow/show-field-item'; +import { SigningOrderConfirmation } from '../document-flow/signing-order-confirmation'; import type { DocumentFlowStep } from '../document-flow/types'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { useStep } from '../stepper'; @@ -205,41 +207,30 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ const items = Array.from(watchedSigners); const [reorderedSigner] = items.splice(result.source.index, 1); - const insertIndex = result.destination.index; items.splice(insertIndex, 0, reorderedSigner); - const updatedSigners = items.map((item, index) => ({ - ...item, + const updatedSigners = items.map((signer, index) => ({ + ...signer, signingOrder: index + 1, })); - updatedSigners.forEach((item, index) => { - const keys: (keyof typeof item)[] = [ - 'formId', - 'nativeId', - 'email', - 'name', - 'role', - 'signingOrder', - 'actionAuth', - ]; - keys.forEach((key) => { - form.setValue(`signers.${index}.${key}` as const, item[key]); - }); - }); + form.setValue('signers', updatedSigners); - const currentLength = form.getValues('signers').length; - if (currentLength > updatedSigners.length) { - for (let i = updatedSigners.length; i < currentLength; i++) { - form.unregister(`signers.${i}`); - } + const lastSigner = updatedSigners[updatedSigners.length - 1]; + if (lastSigner.role === RecipientRole.ASSISTANT) { + toast({ + title: _(msg`Warning: Assistant as last signer`), + description: _( + msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, + ), + }); } await form.trigger('signers'); }, - [form, watchedSigners], + [form, watchedSigners, toast], ); const triggerDragAndDrop = useCallback( @@ -300,26 +291,94 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ const handleSigningOrderChange = useCallback( (index: number, newOrderString: string) => { - const newOrder = parseInt(newOrderString, 10); - - if (!newOrderString.trim()) { + const trimmedOrderString = newOrderString.trim(); + if (!trimmedOrderString) { return; } - if (Number.isNaN(newOrder)) { - form.setValue(`signers.${index}.signingOrder`, index + 1); + const newOrder = Number(trimmedOrderString); + if (!Number.isInteger(newOrder) || newOrder < 1) { return; } - const newIndex = newOrder - 1; - if (index !== newIndex) { - updateSigningOrders(newIndex, index); - triggerDragAndDrop(index, newIndex); + const currentSigners = form.getValues('signers'); + const signer = currentSigners[index]; + + // Remove signer from current position and insert at new position + const remainingSigners = currentSigners.filter((_, idx) => idx !== index); + const newPosition = Math.min(Math.max(0, newOrder - 1), currentSigners.length - 1); + remainingSigners.splice(newPosition, 0, signer); + + const updatedSigners = remainingSigners.map((s, idx) => ({ + ...s, + signingOrder: idx + 1, + })); + + form.setValue('signers', updatedSigners); + + if (signer.role === RecipientRole.ASSISTANT && newPosition === remainingSigners.length - 1) { + toast({ + title: _(msg`Warning: Assistant as last signer`), + description: _( + msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, + ), + }); } }, - [form, triggerDragAndDrop, updateSigningOrders], + [form, toast], ); + const handleRoleChange = useCallback( + (index: number, role: RecipientRole) => { + const currentSigners = form.getValues('signers'); + const signingOrder = form.getValues('signingOrder'); + + // Handle parallel to sequential conversion for assistants + if (role === RecipientRole.ASSISTANT && signingOrder === DocumentSigningOrder.PARALLEL) { + form.setValue('signingOrder', DocumentSigningOrder.SEQUENTIAL); + toast({ + title: _(msg`Signing order is enabled.`), + description: _(msg`You cannot add assistants when signing order is disabled.`), + variant: 'destructive', + }); + return; + } + + const updatedSigners = currentSigners.map((signer, idx) => ({ + ...signer, + role: idx === index ? role : signer.role, + signingOrder: idx + 1, + })); + + form.setValue('signers', updatedSigners); + + if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) { + toast({ + title: _(msg`Warning: Assistant as last signer`), + description: _( + msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`, + ), + }); + } + }, + [form, toast], + ); + + const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false); + + const handleSigningOrderDisable = useCallback(() => { + setShowSigningOrderConfirmation(false); + + const currentSigners = form.getValues('signers'); + const updatedSigners = currentSigners.map((signer) => ({ + ...signer, + role: signer.role === RecipientRole.ASSISTANT ? RecipientRole.SIGNER : signer.role, + })); + + form.setValue('signers', updatedSigners); + form.setValue('signingOrder', DocumentSigningOrder.PARALLEL); + }, [form]); + return ( <> + onCheckedChange={(checked) => { + if ( + !checked && + watchedSigners.some((s) => s.role === RecipientRole.ASSISTANT) + ) { + setShowSigningOrderConfirmation(true); + return; + } + field.onChange( checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL, - ) - } + ); + }} disabled={isSubmitting} /> @@ -548,7 +615,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + handleRoleChange(index, value as RecipientRole) + } disabled={isSubmitting} hideCCRecipients={isSignerDirectRecipient(signer)} /> @@ -669,6 +739,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ onGoNextClick={() => void onFormSubmit()} /> + + ); };