From 332e0657e00b49cb8a32ac43c397c2e4b66f5035 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Sat, 1 Feb 2025 03:31:18 +0000 Subject: [PATCH] feat: assistant role (#1588) ## Description Introduces the ability for users with the **Assistant** role to prefill fields on behalf of other signers. Assistants can fill in various field types such as text, checkboxes, dates, and more, streamlining the document preparation process before it reaches the final signers. https://github.com/user-attachments/assets/c1321578-47ec-405b-a70a-7d9578385895 --- .../[id]/document-page-view-recipients.tsx | 9 +- .../documents/move-document-dialog.tsx | 2 +- .../templates/move-template-dialog.tsx | 2 +- .../templates/template-direct-link-dialog.tsx | 6 +- .../d/[token]/sign-direct-template.tsx | 15 +- .../assistant-confirmation-dialog.tsx | 73 ++++ .../(signing)/sign/[token]/checkbox-field.tsx | 17 +- .../app/(signing)/sign/[token]/date-field.tsx | 15 +- .../(signing)/sign/[token]/dropdown-field.tsx | 17 +- .../(signing)/sign/[token]/email-field.tsx | 12 +- .../src/app/(signing)/sign/[token]/form.tsx | 326 ++++++++++----- .../(signing)/sign/[token]/initials-field.tsx | 15 +- .../app/(signing)/sign/[token]/name-field.tsx | 18 +- .../(signing)/sign/[token]/number-field.tsx | 40 +- .../src/app/(signing)/sign/[token]/page.tsx | 19 +- .../(signing)/sign/[token]/radio-field.tsx | 24 +- .../sign/[token]/recipient-context.tsx | 66 +++ .../sign/[token]/signature-field.tsx | 6 +- .../sign/[token]/signing-field-container.tsx | 1 + .../sign/[token]/signing-page-view.tsx | 305 +++++++------- .../app/(signing)/sign/[token]/text-field.tsx | 22 +- .../app/embed/direct/[[...url]]/client.tsx | 1 - .../src/app/embed/direct/[[...url]]/page.tsx | 23 +- apps/web/src/app/embed/document-fields.tsx | 14 +- .../src/app/embed/sign/[[...url]]/client.tsx | 381 +++++++++++------- .../src/app/embed/sign/[[...url]]/page.tsx | 19 +- apps/web/src/app/embed/waiting-for-turn.tsx | 48 +++ .../(dashboard)/common/command-menu.tsx | 2 +- .../(teams)/dialogs/transfer-team-dialog.tsx | 2 +- .../document/document-history-sheet.tsx | 10 + .../document-flow/stepper-component.spec.ts | 11 +- .../template-document-invite.tsx | 4 + packages/lib/constants/document-audit-logs.ts | 3 + packages/lib/constants/recipient-roles.ts | 15 + .../server-only/document/resend-document.tsx | 2 +- .../server-only/field/get-fields-for-token.ts | 46 ++- .../field/remove-signed-field-with-token.ts | 60 ++- .../field/sign-field-with-token.ts | 45 ++- .../recipient/get-recipient-by-token.ts | 3 + .../recipient/get-recipients-for-assistant.ts | 57 +++ packages/lib/types/document-audit-logs.ts | 80 ++++ packages/lib/utils/document-audit-logs.ts | 4 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + .../prisma/types/recipient-with-fields.ts | 5 + .../recipient/recipient-role-select.tsx | 43 +- .../primitives/document-flow/add-fields.tsx | 28 +- .../primitives/document-flow/add-signers.tsx | 200 ++++----- .../signing-order-confirmation.tsx | 40 ++ packages/ui/primitives/radio-group.tsx | 6 +- .../ui/primitives/recipient-role-icons.tsx | 3 +- .../template-flow/add-template-fields.tsx | 20 +- .../add-template-placeholder-recipients.tsx | 150 +++++-- 53 files changed, 1638 insertions(+), 700 deletions(-) create mode 100644 apps/web/src/app/(signing)/sign/[token]/assistant/assistant-confirmation-dialog.tsx create mode 100644 apps/web/src/app/(signing)/sign/[token]/recipient-context.tsx create mode 100644 apps/web/src/app/embed/waiting-for-turn.tsx create mode 100644 packages/lib/server-only/recipient/get-recipients-for-assistant.ts create mode 100644 packages/prisma/migrations/20250108133544_add_assistant_recipient_role/migration.sql create mode 100644 packages/prisma/types/recipient-with-fields.ts create mode 100644 packages/ui/primitives/document-flow/signing-order-confirmation.tsx diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx index ea8ccee15..48b50a42c 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view-recipients.tsx @@ -12,13 +12,14 @@ import { MailOpenIcon, PenIcon, PlusIcon, + UserIcon, } from 'lucide-react'; import { match } from 'ts-pattern'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { formatSigningLink } from '@documenso/lib/utils/recipients'; -import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { Document, Recipient } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; import { SignatureIcon } from '@documenso/ui/icons/signature'; import { AvatarWithText } from '@documenso/ui/primitives/avatar'; @@ -120,6 +121,12 @@ export const DocumentPageViewRecipients = ({ Viewed )) + .with(RecipientRole.ASSISTANT, () => ( + <> + + Assisted + + )) .exhaustive()} )} diff --git a/apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx index abdd9d817..eceb5faf9 100644 --- a/apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx +++ b/apps/web/src/app/(dashboard)/documents/move-document-dialog.tsx @@ -40,7 +40,7 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum 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/web/src/app/(dashboard)/templates/move-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/move-template-dialog.tsx index 9a00b9d5b..d2e1eff5e 100644 --- a/apps/web/src/app/(dashboard)/templates/move-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/move-template-dialog.tsx @@ -42,7 +42,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl 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: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({ onSuccess: () => { router.refresh(); diff --git a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx index f603f20be..09ad9a13d 100644 --- a/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/template-direct-link-dialog.tsx @@ -77,7 +77,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/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx index 9e379ab41..73e4aa178 100644 --- a/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx +++ b/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx @@ -47,6 +47,7 @@ import { NameField } from '~/app/(signing)/sign/[token]/name-field'; import { NumberField } from '~/app/(signing)/sign/[token]/number-field'; import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; import { RadioField } from '~/app/(signing)/sign/[token]/radio-field'; +import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context'; import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog'; import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field'; import { TextField } from '~/app/(signing)/sign/[token]/text-field'; @@ -169,7 +170,7 @@ export const SignDirectTemplateForm = ({ }; return ( - <> + @@ -186,7 +187,6 @@ export const SignDirectTemplateForm = ({ @@ -195,7 +195,6 @@ export const SignDirectTemplateForm = ({ @@ -204,7 +203,6 @@ export const SignDirectTemplateForm = ({ @@ -213,7 +211,6 @@ export const SignDirectTemplateForm = ({ @@ -241,7 +237,6 @@ export const SignDirectTemplateForm = ({ ...field, fieldMeta: parsedFieldMeta, }} - recipient={directRecipient} onSignField={onSignField} onUnsignField={onUnsignField} /> @@ -259,7 +254,6 @@ export const SignDirectTemplateForm = ({ ...field, fieldMeta: parsedFieldMeta, }} - recipient={directRecipient} onSignField={onSignField} onUnsignField={onUnsignField} /> @@ -277,7 +271,6 @@ export const SignDirectTemplateForm = ({ ...field, fieldMeta: parsedFieldMeta, }} - recipient={directRecipient} onSignField={onSignField} onUnsignField={onUnsignField} /> @@ -295,7 +288,6 @@ export const SignDirectTemplateForm = ({ ...field, fieldMeta: parsedFieldMeta, }} - recipient={directRecipient} onSignField={onSignField} onUnsignField={onUnsignField} /> @@ -313,7 +305,6 @@ export const SignDirectTemplateForm = ({ ...field, fieldMeta: parsedFieldMeta, }} - recipient={directRecipient} onSignField={onSignField} onUnsignField={onUnsignField} /> @@ -383,6 +374,6 @@ export const SignDirectTemplateForm = ({ /> - + ); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/assistant/assistant-confirmation-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/assistant/assistant-confirmation-dialog.tsx new file mode 100644 index 000000000..18be9bcad --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/assistant/assistant-confirmation-dialog.tsx @@ -0,0 +1,73 @@ +import { Trans } from '@lingui/macro'; + +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; + +import { SigningDisclosure } from '~/components/general/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/web/src/app/(signing)/sign/[token]/checkbox-field.tsx b/apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx index 2bf96afdd..78a25d7ea 100644 --- a/apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx @@ -13,7 +13,6 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta'; import { fromCheckboxValue, toCheckboxValue } from '@documenso/lib/universal/field-checkbox'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import { trpc } from '@documenso/trpc/react'; import type { @@ -27,23 +26,19 @@ import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; +import { useRecipientContext } from './recipient-context'; import { SigningFieldContainer } from './signing-field-container'; export type CheckboxFieldProps = { field: FieldWithSignatureAndFieldMeta; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const CheckboxField = ({ - field, - recipient, - onSignField, - onUnsignField, -}: CheckboxFieldProps) => { +export const CheckboxField = ({ field, onSignField, onUnsignField }: CheckboxFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { recipient, targetSigner, isAssistantMode } = useRecipientContext(); const router = useRouter(); const [isPending, startTransition] = useTransition(); @@ -122,7 +117,9 @@ export const CheckboxField = ({ 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', }); } @@ -151,7 +148,7 @@ export const CheckboxField = ({ 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/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index 9d03ee690..fb93d5ee4 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -17,7 +17,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZDateFieldMeta } from '@documenso/lib/types/field-meta'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { @@ -27,11 +26,11 @@ import type { import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { useRecipientContext } from './recipient-context'; import { SigningFieldContainer } from './signing-field-container'; export type DateFieldProps = { field: FieldWithSignature; - recipient: Recipient; dateFormat?: string | null; timezone?: string | null; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; @@ -40,17 +39,17 @@ export type DateFieldProps = { export const DateField = ({ field, - recipient, dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT, timezone = DEFAULT_DOCUMENT_TIME_ZONE, onSignField, onUnsignField, }: DateFieldProps) => { const router = useRouter(); - const { _ } = useLingui(); const { toast } = useToast(); + const { recipient, targetSigner, isAssistantMode } = useRecipientContext(); + const [isPending, startTransition] = useTransition(); const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = @@ -67,9 +66,7 @@ export const DateField = ({ const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; 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}".`, ); @@ -102,7 +99,9 @@ export const DateField = ({ 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', }); } @@ -128,7 +127,7 @@ export const DateField = ({ 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/web/src/app/(signing)/sign/[token]/dropdown-field.tsx b/apps/web/src/app/(signing)/sign/[token]/dropdown-field.tsx index 5f4e1a444..837a966e3 100644 --- a/apps/web/src/app/(signing)/sign/[token]/dropdown-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/dropdown-field.tsx @@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import { trpc } from '@documenso/trpc/react'; import type { @@ -30,23 +29,19 @@ import { import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; +import { useRecipientContext } from './recipient-context'; import { SigningFieldContainer } from './signing-field-container'; export type DropdownFieldProps = { field: FieldWithSignatureAndFieldMeta; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const DropdownField = ({ - field, - recipient, - onSignField, - onUnsignField, -}: DropdownFieldProps) => { +export const DropdownField = ({ field, onSignField, onUnsignField }: DropdownFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { recipient, targetSigner, isAssistantMode } = useRecipientContext(); const router = useRouter(); const [isPending, startTransition] = useTransition(); @@ -103,7 +98,9 @@ export const DropdownField = ({ 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', }); } @@ -134,7 +131,7 @@ export const DropdownField = ({ 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/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx index f3d664e23..a4c0bf2d5 100644 --- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx @@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZEmailFieldMeta } from '@documenso/lib/types/field-meta'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { @@ -23,22 +22,23 @@ import { cn } from '@documenso/ui/lib/utils'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredSigningContext } from './provider'; +import { useRecipientContext } from './recipient-context'; import { SigningFieldContainer } from './signing-field-container'; export type EmailFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const EmailField = ({ field, recipient, onSignField, onUnsignField }: EmailFieldProps) => { +export const EmailField = ({ field, onSignField, onUnsignField }: EmailFieldProps) => { const router = useRouter(); const { _ } = useLingui(); const { toast } = useToast(); const { email: providedEmail } = useRequiredSigningContext(); + const { recipient, targetSigner, isAssistantMode } = useRecipientContext(); const [isPending, startTransition] = useTransition(); @@ -86,7 +86,9 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema 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', }); } @@ -112,7 +114,7 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema 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/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index b69280c71..47033cf11 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -1,19 +1,22 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useId, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Trans } from '@lingui/macro'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; import { useSession } from 'next-auth/react'; -import { useForm } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; 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 Field, FieldType, type Recipient, RecipientRole } from '@documenso/prisma/client'; +import type { Recipient } from '@documenso/prisma/client'; +import { type Field, FieldType, RecipientRole } from '@documenso/prisma/client'; +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'; @@ -21,8 +24,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 './assistant/assistant-confirmation-dialog'; import { useRequiredSigningContext } from './provider'; import { SignDialog } from './sign-dialog'; @@ -32,6 +38,8 @@ export type SigningFormProps = { fields: Field[]; redirectUrl?: string | null; isRecipientsTurn: boolean; + allRecipients?: RecipientWithFields[]; + setSelectedSignerId?: (id: number | null) => void; }; export const SigningForm = ({ @@ -40,19 +48,35 @@ export const SigningForm = ({ fields, redirectUrl, isRecipientsTurn, + allRecipients = [], + setSelectedSignerId, }: SigningFormProps) => { + const { _ } = useLingui(); + const { toast } = useToast(); + const router = useRouter(); const analytics = useAnalytics(); + const { data: session } = useSession(); + const assistantSignersId = useId(); + const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } = useRequiredSigningContext(); 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. @@ -67,7 +91,11 @@ export const SigningForm = ({ 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); @@ -88,12 +116,31 @@ export const SigningForm = ({ } 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) => { @@ -113,7 +160,7 @@ export const SigningForm = ({ }; return ( -
{validateUninsertedFields && uninsertedFields[0] && ( @@ -129,17 +175,13 @@ export const SigningForm = ({ )} -
-
+
+

{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 ? ( @@ -176,91 +218,185 @@ export const SigningForm = ({
+ ) : 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/web/src/app/(signing)/sign/[token]/initials-field.tsx b/apps/web/src/app/(signing)/sign/[token]/initials-field.tsx index b63418076..0f3c6980f 100644 --- a/apps/web/src/app/(signing)/sign/[token]/initials-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/initials-field.tsx @@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { @@ -22,26 +21,22 @@ import type { import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredSigningContext } from './provider'; +import { useRecipientContext } from './recipient-context'; import { SigningFieldContainer } from './signing-field-container'; export type InitialsFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const InitialsField = ({ - field, - recipient, - onSignField, - onUnsignField, -}: InitialsFieldProps) => { +export const InitialsField = ({ field, onSignField, onUnsignField }: InitialsFieldProps) => { const router = useRouter(); const { toast } = useToast(); const { _ } = useLingui(); const { fullName } = useRequiredSigningContext(); + const { recipient, targetSigner, isAssistantMode } = useRecipientContext(); const initials = extractInitials(fullName); const [isPending, startTransition] = useTransition(); @@ -87,7 +82,9 @@ export const InitialsField = ({ 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/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index 1a0756d60..311635026 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZNameFieldMeta } from '@documenso/lib/types/field-meta'; -import { type Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { @@ -28,16 +27,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredSigningContext } from './provider'; +import { useRecipientContext } from './recipient-context'; import { SigningFieldContainer } from './signing-field-container'; export type NameFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const NameField = ({ field, recipient, onSignField, onUnsignField }: NameFieldProps) => { +export const NameField = ({ field, onSignField, onUnsignField }: NameFieldProps) => { const router = useRouter(); const { _ } = useLingui(); @@ -45,6 +44,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name const { fullName: providedFullName, setFullName: setProvidedFullName } = useRequiredSigningContext(); + const { recipient, targetSigner, isAssistantMode } = useRecipientContext(); const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); @@ -67,7 +67,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name const [localFullName, setLocalFullName] = useState(''); const onPreSign = () => { - if (!providedFullName) { + if (!providedFullName && !isAssistantMode) { setShowFullNameModal(true); return false; } @@ -90,9 +90,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name 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 NameField = ({ field, recipient, onSignField, onUnsignField }: Name 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 NameField = ({ field, recipient, onSignField, onUnsignField }: Name 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/web/src/app/(signing)/sign/[token]/number-field.tsx b/apps/web/src/app/(signing)/sign/[token]/number-field.tsx index 07846468c..13d59bb77 100644 --- a/apps/web/src/app/(signing)/sign/[token]/number-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/number-field.tsx @@ -13,7 +13,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { @@ -27,6 +26,7 @@ import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; +import { useRecipientContext } from './recipient-context'; import { SigningFieldContainer } from './signing-field-container'; type ValidationErrors = { @@ -39,18 +39,18 @@ type ValidationErrors = { export type NumberFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const NumberField = ({ field, recipient, onSignField, onUnsignField }: NumberFieldProps) => { +export const NumberField = ({ field, onSignField, onUnsignField }: NumberFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { recipient, targetSigner, isAssistantMode } = useRecipientContext(); const router = useRouter(); const [isPending, startTransition] = useTransition(); - const [showRadioModal, setShowRadioModal] = useState(false); + const [showNumberModal, setShowNumberModal] = useState(false); const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); @@ -105,7 +105,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu }; const onDialogSignClick = () => { - setShowRadioModal(false); + setShowNumberModal(false); void executeActionAuthProcedure({ onReauthFormSubmit: async (authOptions) => await onSign(authOptions), @@ -148,14 +148,20 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu 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); @@ -173,8 +179,14 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu 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, }; @@ -193,18 +205,18 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu 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 ( @@ -235,7 +247,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu onPreSign={onPreSign} onSign={onSign} onRemove={onRemove} - type="Signature" + type="Number" > {isLoading && (
@@ -278,7 +290,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
)} - + {parsedFieldMeta?.label ? parsedFieldMeta?.label : Number} @@ -334,7 +346,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu 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/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index ec32082db..df559123d 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -12,11 +12,12 @@ 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 { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; -import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { DocumentAuthProvider } from './document-auth-provider'; import { NoLongerAvailable } from './no-longer-available'; @@ -43,14 +44,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); - 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 }), ]); @@ -63,12 +64,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp return notFound(); } + const recipientWithFields = { ...recipient, fields }; + const isRecipientsTurn = await getIsRecipientsTurnToSign({ token }); if (!isRecipientsTurn) { return redirect(`/sign/${token}/waiting`); } + const allRecipients = + recipient.role === RecipientRole.ASSISTANT + ? await getRecipientsForAssistant({ + token, + }) + : []; + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ documentAuth: document.authOptions, recipientAuth: recipient.authOptions, @@ -153,11 +163,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp user={user} > diff --git a/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx b/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx index 398181ec1..31e2116a6 100644 --- a/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx @@ -12,7 +12,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import { trpc } from '@documenso/trpc/react'; import type { @@ -24,18 +23,19 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; +import { useRecipientContext } from './recipient-context'; import { SigningFieldContainer } from './signing-field-container'; export type RadioFieldProps = { field: FieldWithSignatureAndFieldMeta; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const RadioField = ({ field, recipient, onSignField, onUnsignField }: RadioFieldProps) => { +export const RadioField = ({ field, onSignField, onUnsignField }: RadioFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { recipient, targetSigner, isAssistantMode } = useRecipientContext(); const router = useRouter(); const [isPending, startTransition] = useTransition(); @@ -68,16 +68,26 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad 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) { @@ -99,7 +109,9 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad 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', }); } @@ -126,7 +138,7 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad 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/web/src/app/(signing)/sign/[token]/recipient-context.tsx b/apps/web/src/app/(signing)/sign/[token]/recipient-context.tsx new file mode 100644 index 000000000..30311274f --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/recipient-context.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { type PropsWithChildren, createContext, useContext } from 'react'; + +import type { Recipient } from '@documenso/prisma/client'; +import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; + +export interface RecipientContextValue { + /** + * 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 RecipientContext = createContext(null); + +export interface RecipientProviderProps extends PropsWithChildren { + recipient: Recipient | RecipientWithFields; + targetSigner?: RecipientWithFields | null; +} + +export const RecipientProvider = ({ + children, + recipient, + targetSigner = null, +}: RecipientProviderProps) => { + // console.log({ + // recipient, + // targetSigner, + // isAssistantMode: !!targetSigner, + // }); + return ( + + {children} + + ); +}; + +export function useRecipientContext() { + const context = useContext(RecipientContext); + + if (!context) { + throw new Error('useRecipientContext must be used within a RecipientProvider'); + } + + return context; +} diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index bba784975..70d10ca09 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -11,7 +11,6 @@ import { Loader } from 'lucide-react'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; -import { type Recipient } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { trpc } from '@documenso/trpc/react'; import type { @@ -28,12 +27,12 @@ import { SigningDisclosure } from '~/components/general/signing-disclosure'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredSigningContext } from './provider'; +import { useRecipientContext } from './recipient-context'; import { SigningFieldContainer } from './signing-field-container'; type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; export type SignatureFieldProps = { field: FieldWithSignature; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; typedSignatureEnabled?: boolean; @@ -41,15 +40,14 @@ export type SignatureFieldProps = { export const SignatureField = ({ field, - recipient, onSignField, onUnsignField, typedSignatureEnabled, }: SignatureFieldProps) => { const router = useRouter(); - const { _ } = useLingui(); const { toast } = useToast(); + const { recipient } = useRecipientContext(); const signatureRef = useRef(null); const containerRef = useRef(null); diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx index cf8403696..a2cdfe9c7 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx @@ -46,6 +46,7 @@ export type SignatureFieldProps = { | 'Email' | 'Name' | 'Signature' + | 'Text' | 'Radio' | 'Dropdown' | 'Number' diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx index 019f3e9c3..9c129edb6 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx @@ -1,3 +1,7 @@ +'use client'; + +import { useState } from 'react'; + import { Trans } from '@lingui/macro'; import { match } from 'ts-pattern'; @@ -13,9 +17,10 @@ import { ZTextFieldMeta, } from '@documenso/lib/types/field-meta'; import type { CompletedField } from '@documenso/lib/types/fields'; -import type { Field, Recipient } from '@documenso/prisma/client'; +import type { Field } from '@documenso/prisma/client'; import { FieldType, RecipientRole } from '@documenso/prisma/client'; 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'; @@ -32,16 +37,18 @@ import { InitialsField } from './initials-field'; import { NameField } from './name-field'; import { NumberField } from './number-field'; import { RadioField } from './radio-field'; +import { RecipientProvider } from './recipient-context'; import { RejectDocumentDialog } from './reject-document-dialog'; import { SignatureField } from './signature-field'; import { TextField } from './text-field'; export type SigningPageViewProps = { document: DocumentAndSender; - recipient: Recipient; + recipient: RecipientWithFields; fields: Field[]; completedFields: CompletedField[]; isRecipientsTurn: boolean; + allRecipients?: RecipientWithFields[]; }; export const SigningPageView = ({ @@ -50,9 +57,12 @@ export const SigningPageView = ({ 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; @@ -64,153 +74,162 @@ export const SigningPageView = ({ 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/web/src/app/(signing)/sign/[token]/text-field.tsx b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx index 3f2229e0c..c5cce7460 100644 --- a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx @@ -13,7 +13,6 @@ import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/tr import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { ZTextFieldMeta } from '@documenso/lib/types/field-meta'; -import type { Recipient } from '@documenso/prisma/client'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import { trpc } from '@documenso/trpc/react'; import type { @@ -27,26 +26,31 @@ import { Textarea } from '@documenso/ui/primitives/textarea'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; +import { useRecipientContext } from './recipient-context'; import { SigningFieldContainer } from './signing-field-container'; +type ValidationErrors = { + required: string[]; + characterLimit: string[]; +}; + export type TextFieldProps = { field: FieldWithSignatureAndFieldMeta; - recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; }; -export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => { +export const TextField = ({ field, onSignField, onUnsignField }: TextFieldProps) => { const { _ } = useLingui(); const { toast } = useToast(); + const { recipient, targetSigner, isAssistantMode } = useRecipientContext(); const router = useRouter(); - const initialErrors: Record = { + const initialErrors: ValidationErrors = { required: [], characterLimit: [], }; - const [errors, setErrors] = useState(initialErrors); const userInputHasErrors = Object.values(errors).some((error) => error.length > 0); @@ -166,7 +170,9 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text 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 +200,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text 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 +240,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text onPreSign={onPreSign} onSign={onSign} onRemove={onRemove} - type="Signature" + type="Text" > {isLoading && (
diff --git a/apps/web/src/app/embed/direct/[[...url]]/client.tsx b/apps/web/src/app/embed/direct/[[...url]]/client.tsx index 266672209..dbb74a36a 100644 --- a/apps/web/src/app/embed/direct/[[...url]]/client.tsx +++ b/apps/web/src/app/embed/direct/[[...url]]/client.tsx @@ -485,7 +485,6 @@ export const EmbedDirectTemplateClientPage = ({ {/* Fields */} - + + + ); diff --git a/apps/web/src/app/embed/document-fields.tsx b/apps/web/src/app/embed/document-fields.tsx index 79256b07e..1cc95d7cb 100644 --- a/apps/web/src/app/embed/document-fields.tsx +++ b/apps/web/src/app/embed/document-fields.tsx @@ -12,7 +12,7 @@ import { ZRadioFieldMeta, ZTextFieldMeta, } from '@documenso/lib/types/field-meta'; -import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client'; +import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client'; import { type Field, FieldType } from '@documenso/prisma/client'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import type { @@ -33,7 +33,6 @@ import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field'; import { TextField } from '~/app/(signing)/sign/[token]/text-field'; export type EmbedDocumentFieldsProps = { - recipient: Recipient; fields: Field[]; metadata?: DocumentMeta | TemplateMeta | null; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; @@ -41,7 +40,6 @@ export type EmbedDocumentFieldsProps = { }; export const EmbedDocumentFields = ({ - recipient, fields, metadata, onSignField, @@ -55,7 +53,6 @@ export const EmbedDocumentFields = ({ @@ -74,7 +70,6 @@ export const EmbedDocumentFields = ({ @@ -83,7 +78,6 @@ export const EmbedDocumentFields = ({ @@ -109,7 +102,6 @@ export const EmbedDocumentFields = ({ @@ -125,7 +117,6 @@ export const EmbedDocumentFields = ({ @@ -141,7 +132,6 @@ export const EmbedDocumentFields = ({ @@ -157,7 +147,6 @@ export const EmbedDocumentFields = ({ @@ -173,7 +162,6 @@ export const EmbedDocumentFields = ({ diff --git a/apps/web/src/app/embed/sign/[[...url]]/client.tsx b/apps/web/src/app/embed/sign/[[...url]]/client.tsx index bfaeaeec5..279ee50b2 100644 --- a/apps/web/src/app/embed/sign/[[...url]]/client.tsx +++ b/apps/web/src/app/embed/sign/[[...url]]/client.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useLayoutEffect, useState } from 'react'; +import { useEffect, useId, useLayoutEffect, useState } from 'react'; import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; @@ -9,8 +9,9 @@ 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 { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client'; -import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client'; +import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client'; +import { type DocumentData, type Field, FieldType, RecipientRole } from '@documenso/prisma/client'; +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'; @@ -19,10 +20,12 @@ 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'; import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; +import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context'; import { Logo } from '~/components/branding/logo'; import { EmbedClientLoading } from '../../client-loading'; @@ -35,12 +38,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 = ({ @@ -53,6 +57,7 @@ export const EmbedSignDocumentClientPage = ({ isCompleted, hidePoweredBy = false, isPlatformOrEnterprise = false, + allRecipients = [], }: EmbedSignDocumentClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -70,17 +75,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), ]; @@ -89,6 +98,8 @@ export const EmbedSignDocumentClientPage = ({ const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); + const assistantSignersId = useId(); + const onNextFieldClick = () => { validateFieldsInserted(fields); @@ -214,164 +225,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/web/src/app/embed/sign/[[...url]]/page.tsx b/apps/web/src/app/embed/sign/[[...url]]/page.tsx index c07cd0be3..0e9ac7a60 100644 --- a/apps/web/src/app/embed/sign/[[...url]]/page.tsx +++ b/apps/web/src/app/embed/sign/[[...url]]/page.tsx @@ -8,17 +8,20 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; 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'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole } from '@documenso/prisma/client'; import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider'; import { SigningProvider } from '~/app/(signing)/sign/[token]/provider'; import { EmbedAuthenticateView } from '../../authenticate'; import { EmbedPaywall } from '../../paywall'; +import { EmbedWaitingForTurn } from '../../waiting-for-turn'; import { EmbedSignDocumentClientPage } from './client'; export type EmbedSignDocumentPageProps = { @@ -85,6 +88,19 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen ); } + const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token }); + + if (!isRecipientsTurnToSign) { + return ; + } + + const allRecipients = + recipient.role === RecipientRole.ASSISTANT + ? await getRecipientsForAssistant({ + token, + }) + : []; + const team = document.teamId ? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null) : null; @@ -112,6 +128,7 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen isCompleted={document.status === DocumentStatus.COMPLETED} hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy} isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument} + allRecipients={allRecipients} /> diff --git a/apps/web/src/app/embed/waiting-for-turn.tsx b/apps/web/src/app/embed/waiting-for-turn.tsx new file mode 100644 index 000000000..fe034f771 --- /dev/null +++ b/apps/web/src/app/embed/waiting-for-turn.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { Trans } from '@lingui/macro'; + +export const EmbedWaitingForTurn = () => { + 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/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index e07ed9f1b..7d3b91d52 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -85,7 +85,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { 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/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx index fa991099b..390c138b3 100644 --- a/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx +++ b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx @@ -67,7 +67,7 @@ export const TransferTeamDialog = ({ const { data, refetch: refetchTeamMembers, - isLoading: loadingTeamMembers, + isPending: loadingTeamMembers, isLoadingError: loadingTeamMembersError, } = trpc.team.getTeamMembers.useQuery({ teamId, diff --git a/apps/web/src/components/document/document-history-sheet.tsx b/apps/web/src/components/document/document-history-sheet.tsx index 8bda3a424..cb607a125 100644 --- a/apps/web/src/components/document/document-history-sheet.tsx +++ b/apps/web/src/components/document/document-history-sheet.tsx @@ -353,6 +353,16 @@ export const DocumentHistorySheet = ({ /> ), ) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => ( + + )) .exhaustive()} {isUserDetailsVisible && ( 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 da4eae6d7..d53f33d11 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -540,12 +540,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 c8b4a402d..998d8549c 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 ad994c98d..5adc8a735 100644 --- a/packages/lib/constants/recipient-roles.ts +++ b/packages/lib/constants/recipient-roles.ts @@ -32,12 +32,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 = { @@ -45,4 +59,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/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index 6f00cbc78..e803f5d7a 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -14,8 +14,8 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { Prisma } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { getI18nInstance } from '../../client-only/providers/i18n.server'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; 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 654dfec20..ba56305e1 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 @@ -4,7 +4,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; export type RemovedSignedFieldWithTokenOptions = { token: string; @@ -17,11 +17,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: { @@ -30,7 +47,7 @@ export const removeSignedFieldWithToken = async ({ }, }); - const { document, recipient } = field; + const { document } = field; if (!document) { throw new Error(`Document not found for field ${field.id}`); @@ -40,7 +57,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`); } @@ -66,20 +86,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 f5b170ba5..f94dc8de0 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -10,7 +10,7 @@ import { validateRadioField } from '@documenso/lib/advanced-fields-validation/va import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text'; import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox'; import { prisma } from '@documenso/prisma'; -import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; @@ -56,20 +56,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}`); @@ -87,7 +108,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`); } @@ -183,6 +207,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: { @@ -219,11 +245,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 73073f7a8..e0b251f5d 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -28,6 +28,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. @@ -45,6 +46,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([ 'SIGNING_REQUEST', 'VIEW_REQUEST', 'APPROVE_REQUEST', + 'ASSISTING_REQUEST', 'CC', 'DOCUMENT_COMPLETED', ]); @@ -313,6 +315,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, @@ -493,6 +572,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 339bf453b..44ae23ac0 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -314,6 +314,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 44e0bfeee..0cdb1521e 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -417,6 +417,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 4d72b7be1..8559a9b59 100644 --- a/packages/ui/components/recipient/recipient-role-select.tsx +++ b/packages/ui/components/recipient/recipient-role-select.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { forwardRef } from 'react'; +import { forwardRef } from 'react'; import { Trans } from '@lingui/macro'; import type { SelectProps } from '@radix-ui/react-select'; @@ -11,12 +11,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 79a22e83c..f6b1572d8 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -508,7 +508,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(() => { @@ -517,6 +525,7 @@ export const AddFieldsFormPartial = ({ VIEWER: [], SIGNER: [], APPROVER: [], + ASSISTANT: [], }; recipients.forEach((recipient) => { @@ -529,7 +538,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 @@ -544,12 +558,6 @@ export const AddFieldsFormPartial = ({ ); }, [recipientsByRole]); - const isTypedSignatureEnabled = form.watch('typedSignatureEnabled'); - - const handleTypedSignatureChange = (value: boolean) => { - form.setValue('typedSignatureEnabled', value, { shouldDirty: true }); - }; - const handleAdvancedSettings = () => { setShowAdvancedSettings((prev) => !prev); }; @@ -687,9 +695,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 395ed6dcd..5ece17491 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -41,6 +41,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 = { @@ -123,6 +124,7 @@ export const AddSignersFormPartial = ({ }, [recipients, form]); const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings); + const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false); const { setValue, @@ -134,6 +136,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)) @@ -233,6 +239,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++; @@ -240,126 +247,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} /> @@ -613,7 +615,11 @@ export const AddSignersFormPartial = ({ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + handleRoleChange(index, value as RecipientRole) + } disabled={ snapshot.isDragging || isSubmitting || @@ -710,6 +716,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 1daa806e1..176c6f2f0 100644 --- a/packages/ui/primitives/radio-group.tsx +++ b/packages/ui/primitives/radio-group.tsx @@ -19,18 +19,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 5bc4f34b9..f6db9df9a 100644 --- a/packages/ui/primitives/recipient-role-icons.tsx +++ b/packages/ui/primitives/recipient-role-icons.tsx @@ -1,4 +1,4 @@ -import { BadgeCheck, Copy, Eye, PencilLine } from 'lucide-react'; +import { BadgeCheck, Copy, Eye, PencilLine, User } from 'lucide-react'; import type { RecipientRole } from '.prisma/client'; @@ -7,4 +7,5 @@ export const ROLE_ICONS: Record = { 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 c26c567e1..13d96b56a 100644 --- a/packages/ui/primitives/template-flow/add-template-fields.tsx +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -32,7 +32,7 @@ import { import { nanoid } from '@documenso/lib/universal/id'; import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; import type { Field, Recipient } from '@documenso/prisma/client'; -import { FieldType, RecipientRole } from '@documenso/prisma/client'; +import { FieldType, RecipientRole, SendStatus } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -438,6 +438,7 @@ export const AddTemplateFieldsFormPartial = ({ VIEWER: [], SIGNER: [], APPROVER: [], + ASSISTANT: [], }; recipients.forEach((recipient) => { @@ -447,10 +448,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 bf8dc0bd0..7312bf6ee 100644 --- a/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx +++ b/packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx @@ -29,6 +29,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 { @@ -39,6 +40,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'; @@ -213,41 +215,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( @@ -308,26 +299,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} /> @@ -556,7 +623,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + handleRoleChange(index, value as RecipientRole) + } disabled={isSubmitting} hideCCRecipients={isSignerDirectRecipient(signer)} /> @@ -677,6 +747,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({ onGoNextClick={() => void onFormSubmit()} /> + + ); };