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()} /> + + ); };