feat: assistant role (#1588)

## Description

Introduces the ability for users with the **Assistant** role to prefill
fields on behalf of other signers. Assistants can fill in various field
types such as text, checkboxes, dates, and more, streamlining the
document preparation process before it reaches the final signers.

https://github.com/user-attachments/assets/c1321578-47ec-405b-a70a-7d9578385895
This commit is contained in:
Ephraim Duncan
2025-02-01 03:31:18 +00:00
committed by GitHub
parent 4017b250fb
commit 332e0657e0
53 changed files with 1638 additions and 700 deletions

View File

@ -12,13 +12,14 @@ import {
MailOpenIcon, MailOpenIcon,
PenIcon, PenIcon,
PlusIcon, PlusIcon,
UserIcon,
} from 'lucide-react'; } from 'lucide-react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { formatSigningLink } from '@documenso/lib/utils/recipients'; import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Recipient } 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 { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature'; import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -120,6 +121,12 @@ export const DocumentPageViewRecipients = ({
<Trans>Viewed</Trans> <Trans>Viewed</Trans>
</> </>
)) ))
.with(RecipientRole.ASSISTANT, () => (
<>
<UserIcon className="mr-1 h-3 w-3" />
<Trans>Assisted</Trans>
</>
))
.exhaustive()} .exhaustive()}
</Badge> </Badge>
)} )}

View File

@ -40,7 +40,7 @@ export const MoveDocumentDialog = ({ documentId, open, onOpenChange }: MoveDocum
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null); const [selectedTeamId, setSelectedTeamId] = useState<number | null>(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({ const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
onSuccess: () => { onSuccess: () => {

View File

@ -42,7 +42,7 @@ export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTempl
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null); const [selectedTeamId, setSelectedTeamId] = useState<number | null>(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({ const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
onSuccess: () => { onSuccess: () => {
router.refresh(); router.refresh();

View File

@ -77,7 +77,11 @@ export const TemplateDirectLinkDialog = ({
); );
const validDirectTemplateRecipients = useMemo( 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], [template.recipients],
); );

View File

@ -47,6 +47,7 @@ import { NameField } from '~/app/(signing)/sign/[token]/name-field';
import { NumberField } from '~/app/(signing)/sign/[token]/number-field'; import { NumberField } from '~/app/(signing)/sign/[token]/number-field';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { RadioField } from '~/app/(signing)/sign/[token]/radio-field'; 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 { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field'; import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
import { TextField } from '~/app/(signing)/sign/[token]/text-field'; import { TextField } from '~/app/(signing)/sign/[token]/text-field';
@ -169,7 +170,7 @@ export const SignDirectTemplateForm = ({
}; };
return ( return (
<> <RecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} /> <DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
@ -186,7 +187,6 @@ export const SignDirectTemplateForm = ({
<SignatureField <SignatureField
key={field.id} key={field.id}
field={field} field={field}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -195,7 +195,6 @@ export const SignDirectTemplateForm = ({
<InitialsField <InitialsField
key={field.id} key={field.id}
field={field} field={field}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -204,7 +203,6 @@ export const SignDirectTemplateForm = ({
<NameField <NameField
key={field.id} key={field.id}
field={field} field={field}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -213,7 +211,6 @@ export const SignDirectTemplateForm = ({
<DateField <DateField
key={field.id} key={field.id}
field={field} field={field}
recipient={directRecipient}
dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT} dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE} timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
onSignField={onSignField} onSignField={onSignField}
@ -224,7 +221,6 @@ export const SignDirectTemplateForm = ({
<EmailField <EmailField
key={field.id} key={field.id}
field={field} field={field}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -241,7 +237,6 @@ export const SignDirectTemplateForm = ({
...field, ...field,
fieldMeta: parsedFieldMeta, fieldMeta: parsedFieldMeta,
}} }}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -259,7 +254,6 @@ export const SignDirectTemplateForm = ({
...field, ...field,
fieldMeta: parsedFieldMeta, fieldMeta: parsedFieldMeta,
}} }}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -277,7 +271,6 @@ export const SignDirectTemplateForm = ({
...field, ...field,
fieldMeta: parsedFieldMeta, fieldMeta: parsedFieldMeta,
}} }}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -295,7 +288,6 @@ export const SignDirectTemplateForm = ({
...field, ...field,
fieldMeta: parsedFieldMeta, fieldMeta: parsedFieldMeta,
}} }}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -313,7 +305,6 @@ export const SignDirectTemplateForm = ({
...field, ...field,
fieldMeta: parsedFieldMeta, fieldMeta: parsedFieldMeta,
}} }}
recipient={directRecipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -383,6 +374,6 @@ export const SignDirectTemplateForm = ({
/> />
</div> </div>
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>
</> </RecipientProvider>
); );
}; };

View File

@ -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 (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Complete Document</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
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.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<SigningDisclosure />
</div>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
variant={hasUninsertedFields ? 'destructive' : 'default'}
onClick={onConfirm}
disabled={isSubmitting}
loading={isSubmitting}
>
{isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -13,7 +13,6 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta'; import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import { fromCheckboxValue, toCheckboxValue } from '@documenso/lib/universal/field-checkbox'; 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 type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -27,23 +26,19 @@ import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type CheckboxFieldProps = { export type CheckboxFieldProps = {
field: FieldWithSignatureAndFieldMeta; field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
}; };
export const CheckboxField = ({ export const CheckboxField = ({ field, onSignField, onUnsignField }: CheckboxFieldProps) => {
field,
recipient,
onSignField,
onUnsignField,
}: CheckboxFieldProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -122,7 +117,9 @@ export const CheckboxField = ({
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -151,7 +148,7 @@ export const CheckboxField = ({
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }

View File

@ -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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZDateFieldMeta } from '@documenso/lib/types/field-meta'; 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 type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -27,11 +26,11 @@ import type {
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type DateFieldProps = { export type DateFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient;
dateFormat?: string | null; dateFormat?: string | null;
timezone?: string | null; timezone?: string | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
@ -40,17 +39,17 @@ export type DateFieldProps = {
export const DateField = ({ export const DateField = ({
field, field,
recipient,
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT, dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
timezone = DEFAULT_DOCUMENT_TIME_ZONE, timezone = DEFAULT_DOCUMENT_TIME_ZONE,
onSignField, onSignField,
onUnsignField, onUnsignField,
}: DateFieldProps) => { }: DateFieldProps) => {
const router = useRouter(); const router = useRouter();
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
@ -67,9 +66,7 @@ export const DateField = ({
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone); const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
const isDifferentTime = field.inserted && localDateString !== field.customText; const isDifferentTime = field.inserted && localDateString !== field.customText;
const tooltipText = _( const tooltipText = _(
msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`, msg`"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`,
); );
@ -102,7 +99,9 @@ export const DateField = ({
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -128,7 +127,7 @@ export const DateField = ({
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }

View File

@ -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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta'; 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 type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -30,23 +29,19 @@ import {
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type DropdownFieldProps = { export type DropdownFieldProps = {
field: FieldWithSignatureAndFieldMeta; field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
}; };
export const DropdownField = ({ export const DropdownField = ({ field, onSignField, onUnsignField }: DropdownFieldProps) => {
field,
recipient,
onSignField,
onUnsignField,
}: DropdownFieldProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -103,7 +98,9 @@ export const DropdownField = ({
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -134,7 +131,7 @@ export const DropdownField = ({
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }

View File

@ -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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZEmailFieldMeta } from '@documenso/lib/types/field-meta'; 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 type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -23,22 +22,23 @@ import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider'; import { useRequiredSigningContext } from './provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type EmailFieldProps = { export type EmailFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
}; };
export const EmailField = ({ field, recipient, onSignField, onUnsignField }: EmailFieldProps) => { export const EmailField = ({ field, onSignField, onUnsignField }: EmailFieldProps) => {
const router = useRouter(); const router = useRouter();
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { email: providedEmail } = useRequiredSigningContext(); const { email: providedEmail } = useRequiredSigningContext();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -86,7 +86,9 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -112,7 +114,7 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }

View File

@ -1,19 +1,22 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useId, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; 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 { 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 { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; 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 { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils'; 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 { Card, CardContent } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; 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 { 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 { useRequiredSigningContext } from './provider';
import { SignDialog } from './sign-dialog'; import { SignDialog } from './sign-dialog';
@ -32,6 +38,8 @@ export type SigningFormProps = {
fields: Field[]; fields: Field[];
redirectUrl?: string | null; redirectUrl?: string | null;
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
setSelectedSignerId?: (id: number | null) => void;
}; };
export const SigningForm = ({ export const SigningForm = ({
@ -40,19 +48,35 @@ export const SigningForm = ({
fields, fields,
redirectUrl, redirectUrl,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [],
setSelectedSignerId,
}: SigningFormProps) => { }: SigningFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const analytics = useAnalytics(); const analytics = useAnalytics();
const { data: session } = useSession(); const { data: session } = useSession();
const assistantSignersId = useId();
const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } = const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
useRequiredSigningContext(); useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
const { mutateAsync: completeDocumentWithToken } = const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation(); trpc.recipient.completeDocumentWithToken.useMutation();
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
defaultValues: {
selectedSignerId: undefined,
},
});
const { handleSubmit, formState } = useForm(); const { handleSubmit, formState } = useForm();
// Keep the loading state going if successful since the redirect may take some time. // Keep the loading state going if successful since the redirect may take some time.
@ -67,7 +91,11 @@ export const SigningForm = ({
const uninsertedFields = useMemo(() => { const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted)); return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
}, [fields]); }, [fieldsRequiringValidation]);
const uninsertedRecipientFields = useMemo(() => {
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
}, [fieldsRequiringValidation, recipient]);
const fieldsValidated = () => { const fieldsValidated = () => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
@ -88,12 +116,31 @@ export const SigningForm = ({
} }
await completeDocument(); await completeDocument();
};
// Reauth is currently not required for completing the document. const onAssistantFormSubmit = () => {
// await executeActionAuthProcedure({ if (uninsertedRecipientFields.length > 0) {
// onReauthFormSubmit: completeDocument, return;
// actionTarget: 'DOCUMENT', }
// });
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) => { const completeDocument = async (authOptions?: TRecipientActionAuth) => {
@ -113,7 +160,7 @@ export const SigningForm = ({
}; };
return ( return (
<form <div
className={cn( className={cn(
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6', 'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
{ {
@ -121,7 +168,6 @@ export const SigningForm = ({
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !session, 'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !session,
}, },
)} )}
onSubmit={handleSubmit(onFormSubmit)}
> >
{validateUninsertedFields && uninsertedFields[0] && ( {validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning"> <FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
@ -129,17 +175,13 @@ export const SigningForm = ({
</FieldToolTip> </FieldToolTip>
)} )}
<fieldset <div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
disabled={isSubmitting} <div className="flex flex-1 flex-col">
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold"> <h3 className="text-foreground text-2xl font-semibold">
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>} {recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>} {recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>} {recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
</h3> </h3>
{recipient.role === RecipientRole.VIEWER ? ( {recipient.role === RecipientRole.VIEWER ? (
@ -176,15 +218,108 @@ export const SigningForm = ({
</div> </div>
</div> </div>
</> </>
) : recipient.role === RecipientRole.ASSISTANT ? (
<>
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
Complete the fields for the following signers. Once reviewed, they will inform
you if any modifications are needed.
</Trans>
</p>
<hr className="border-border my-4" />
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
<Controller
name="selectedSignerId"
control={assistantForm.control}
rules={{ required: 'Please select a signer' }}
render={({ field }) => (
<RadioGroup
className="gap-0 space-y-3 shadow-none"
value={field.value?.toString()}
onValueChange={(value) => {
field.onChange(value);
setSelectedSignerId?.(Number(value));
}}
>
{allRecipients
.filter((r) => r.fields.length > 0)
.map((r) => (
<div
key={`${assistantSignersId}-${r.id}`}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RadioGroupItem
id={`${assistantSignersId}-${r.id}`}
value={r.id.toString()}
className="after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label
className="inline-flex items-start"
htmlFor={`${assistantSignersId}-${r.id}`}
>
{r.name}
{r.id === recipient.id && (
<span className="text-muted-foreground ml-2">
{_(msg`(You)`)}
</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
</div>
))}
</RadioGroup>
)}
/>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="submit"
className="w-full"
size="lg"
loading={isAssistantSubmitting}
disabled={isAssistantSubmitting || uninsertedRecipientFields.length > 0}
>
{isAssistantSubmitting ? <Trans>Submitting...</Trans> : <Trans>Continue</Trans>}
</Button>
</div>
<AssistantConfirmationDialog
hasUninsertedFields={uninsertedFields.length > 0}
isOpen={isConfirmationDialogOpen}
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting}
/>
</form>
</>
) : ( ) : (
<> <>
<form onSubmit={handleSubmit(onFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
<Trans>Please review the document before signing.</Trans> <Trans>Please review the document before signing.</Trans>
</p> </p>
<hr className="border-border mb-8 mt-4" /> <hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"> <fieldset
disabled={isSubmitting}
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
>
<div className="flex flex-1 flex-col gap-y-4"> <div className="flex flex-1 flex-col gap-y-4">
<div> <div>
<Label htmlFor="full-name"> <Label htmlFor="full-name">
@ -256,11 +391,12 @@ export const SigningForm = ({
disabled={!isRecipientsTurn} disabled={!isRecipientsTurn}
/> />
</div> </div>
</div> </fieldset>
</form>
</> </>
)} )}
</div> </div>
</fieldset> </div>
</form> </div>
); );
}; };

View File

@ -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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; 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 type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -22,26 +21,22 @@ import type {
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from './provider'; import { useRequiredSigningContext } from './provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type InitialsFieldProps = { export type InitialsFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
}; };
export const InitialsField = ({ export const InitialsField = ({ field, onSignField, onUnsignField }: InitialsFieldProps) => {
field,
recipient,
onSignField,
onUnsignField,
}: InitialsFieldProps) => {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { _ } = useLingui(); const { _ } = useLingui();
const { fullName } = useRequiredSigningContext(); const { fullName } = useRequiredSigningContext();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const initials = extractInitials(fullName); const initials = extractInitials(fullName);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -87,7 +82,9 @@ export const InitialsField = ({
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }

View File

@ -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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZNameFieldMeta } from '@documenso/lib/types/field-meta'; 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 type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -28,16 +27,16 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider'; import { useRequiredSigningContext } from './provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type NameFieldProps = { export type NameFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
}; };
export const NameField = ({ field, recipient, onSignField, onUnsignField }: NameFieldProps) => { export const NameField = ({ field, onSignField, onUnsignField }: NameFieldProps) => {
const router = useRouter(); const router = useRouter();
const { _ } = useLingui(); const { _ } = useLingui();
@ -45,6 +44,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
const { fullName: providedFullName, setFullName: setProvidedFullName } = const { fullName: providedFullName, setFullName: setProvidedFullName } =
useRequiredSigningContext(); useRequiredSigningContext();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
@ -67,7 +67,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
const [localFullName, setLocalFullName] = useState(''); const [localFullName, setLocalFullName] = useState('');
const onPreSign = () => { const onPreSign = () => {
if (!providedFullName) { if (!providedFullName && !isAssistantMode) {
setShowFullNameModal(true); setShowFullNameModal(true);
return false; return false;
} }
@ -90,9 +90,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => { const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
try { try {
const value = name || providedFullName; const value = name || providedFullName || '';
if (!value) { if (!value && !isAssistantMode) {
setShowFullNameModal(true); setShowFullNameModal(true);
return; return;
} }
@ -124,7 +124,9 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -150,7 +152,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }

View File

@ -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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta'; 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 type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -27,6 +26,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
type ValidationErrors = { type ValidationErrors = {
@ -39,18 +39,18 @@ type ValidationErrors = {
export type NumberFieldProps = { export type NumberFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
}; };
export const NumberField = ({ field, recipient, onSignField, onUnsignField }: NumberFieldProps) => { export const NumberField = ({ field, onSignField, onUnsignField }: NumberFieldProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [showRadioModal, setShowRadioModal] = useState(false); const [showNumberModal, setShowNumberModal] = useState(false);
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
@ -105,7 +105,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
}; };
const onDialogSignClick = () => { const onDialogSignClick = () => {
setShowRadioModal(false); setShowNumberModal(false);
void executeActionAuthProcedure({ void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions), onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
@ -148,14 +148,20 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
}; };
const onPreSign = () => { const onPreSign = () => {
setShowRadioModal(true); if (isAssistantMode) {
return true;
}
setShowNumberModal(true);
if (localNumber && parsedFieldMeta) { if (localNumber && parsedFieldMeta) {
const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true); const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true);
@ -173,8 +179,14 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
const onRemove = async () => { const onRemove = async () => {
try { try {
if (isAssistantMode && !targetSigner) {
return;
}
const signingRecipient = isAssistantMode && targetSigner ? targetSigner : recipient;
const payload: TRemovedSignedFieldWithTokenMutationSchema = { const payload: TRemovedSignedFieldWithTokenMutationSchema = {
token: recipient.token, token: signingRecipient.token,
fieldId: field.id, fieldId: field.id,
}; };
@ -193,18 +205,18 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }
}; };
useEffect(() => { useEffect(() => {
if (!showRadioModal) { if (!showNumberModal) {
setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0'); setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0');
setErrors(initialErrors); setErrors(initialErrors);
} }
}, [showRadioModal]); }, [showNumberModal]);
useEffect(() => { useEffect(() => {
if ( if (
@ -235,7 +247,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
onPreSign={onPreSign} onPreSign={onPreSign}
onSign={onSign} onSign={onSign}
onRemove={onRemove} onRemove={onRemove}
type="Signature" type="Number"
> >
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
@ -278,7 +290,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
</div> </div>
)} )}
<Dialog open={showRadioModal} onOpenChange={setShowRadioModal}> <Dialog open={showNumberModal} onOpenChange={setShowNumberModal}>
<DialogContent> <DialogContent>
<DialogTitle> <DialogTitle>
{parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>} {parsedFieldMeta?.label ? parsedFieldMeta?.label : <Trans>Number</Trans>}
@ -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" className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setShowRadioModal(false); setShowNumberModal(false);
setLocalNumber(''); setLocalNumber('');
}} }}
> >

View File

@ -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 { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
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 { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; 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 { DocumentAuthProvider } from './document-auth-provider';
import { NoLongerAvailable } from './no-longer-available'; import { NoLongerAvailable } from './no-longer-available';
@ -43,14 +44,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const [document, fields, recipient, completedFields] = await Promise.all([ const [document, recipient, fields, completedFields] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
token, token,
userId: user?.id, userId: user?.id,
requireAccessAuth: false, requireAccessAuth: false,
}).catch(() => null), }).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
getFieldsForToken({ token }),
getCompletedFieldsForToken({ token }), getCompletedFieldsForToken({ token }),
]); ]);
@ -63,12 +64,21 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound(); return notFound();
} }
const recipientWithFields = { ...recipient, fields };
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token }); const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) { if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`); return redirect(`/sign/${token}/waiting`);
} }
const allRecipients =
recipient.role === RecipientRole.ASSISTANT
? await getRecipientsForAssistant({
token,
})
: [];
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions, documentAuth: document.authOptions,
recipientAuth: recipient.authOptions, recipientAuth: recipient.authOptions,
@ -153,11 +163,12 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
user={user} user={user}
> >
<SigningPageView <SigningPageView
recipient={recipient} recipient={recipientWithFields}
document={document} document={document}
fields={fields} fields={fields}
completedFields={completedFields} completedFields={completedFields}
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
/> />
</DocumentAuthProvider> </DocumentAuthProvider>
</SigningProvider> </SigningProvider>

View File

@ -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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta'; 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 type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -24,18 +23,19 @@ import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
export type RadioFieldProps = { export type RadioFieldProps = {
field: FieldWithSignatureAndFieldMeta; field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
}; };
export const RadioField = ({ field, recipient, onSignField, onUnsignField }: RadioFieldProps) => { export const RadioField = ({ field, onSignField, onUnsignField }: RadioFieldProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -68,16 +68,26 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
const onSign = async (authOptions?: TRecipientActionAuth) => { const onSign = async (authOptions?: TRecipientActionAuth) => {
try { try {
if (isAssistantMode && !targetSigner) {
return;
}
if (!selectedOption) { if (!selectedOption) {
return; return;
} }
const signingRecipient = isAssistantMode && targetSigner ? targetSigner : recipient;
const payload: TSignFieldWithTokenMutationSchema = { const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token, token: signingRecipient.token,
fieldId: field.id, fieldId: field.id,
value: selectedOption, value: selectedOption,
isBase64: true, isBase64: true,
authOptions, authOptions,
...(isAssistantMode && {
isAssistantPrefill: true,
assistantId: recipient.id,
}),
}; };
if (onSignField) { if (onSignField) {
@ -99,7 +109,9 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -126,7 +138,7 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the signature.`), description: _(msg`An error occurred while removing the selection.`),
variant: 'destructive', variant: 'destructive',
}); });
} }

View File

@ -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<RecipientContextValue | null>(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 (
<RecipientContext.Provider
value={{
recipient,
targetSigner,
isAssistantMode: !!targetSigner,
}}
>
{children}
</RecipientContext.Provider>
);
};
export function useRecipientContext() {
const context = useContext(RecipientContext);
if (!context) {
throw new Error('useRecipientContext must be used within a RecipientProvider');
}
return context;
}

View File

@ -11,7 +11,6 @@ import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; 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 type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -28,12 +27,12 @@ import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider'; import { useRequiredSigningContext } from './provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
export type SignatureFieldProps = { export type SignatureFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
typedSignatureEnabled?: boolean; typedSignatureEnabled?: boolean;
@ -41,15 +40,14 @@ export type SignatureFieldProps = {
export const SignatureField = ({ export const SignatureField = ({
field, field,
recipient,
onSignField, onSignField,
onUnsignField, onUnsignField,
typedSignatureEnabled, typedSignatureEnabled,
}: SignatureFieldProps) => { }: SignatureFieldProps) => {
const router = useRouter(); const router = useRouter();
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient } = useRecipientContext();
const signatureRef = useRef<HTMLParagraphElement>(null); const signatureRef = useRef<HTMLParagraphElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);

View File

@ -46,6 +46,7 @@ export type SignatureFieldProps = {
| 'Email' | 'Email'
| 'Name' | 'Name'
| 'Signature' | 'Signature'
| 'Text'
| 'Radio' | 'Radio'
| 'Dropdown' | 'Dropdown'
| 'Number' | 'Number'

View File

@ -1,3 +1,7 @@
'use client';
import { useState } from 'react';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -13,9 +17,10 @@ import {
ZTextFieldMeta, ZTextFieldMeta,
} from '@documenso/lib/types/field-meta'; } from '@documenso/lib/types/field-meta';
import type { CompletedField } from '@documenso/lib/types/fields'; 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 { FieldType, RecipientRole } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; 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 { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -32,16 +37,18 @@ import { InitialsField } from './initials-field';
import { NameField } from './name-field'; import { NameField } from './name-field';
import { NumberField } from './number-field'; import { NumberField } from './number-field';
import { RadioField } from './radio-field'; import { RadioField } from './radio-field';
import { RecipientProvider } from './recipient-context';
import { RejectDocumentDialog } from './reject-document-dialog'; import { RejectDocumentDialog } from './reject-document-dialog';
import { SignatureField } from './signature-field'; import { SignatureField } from './signature-field';
import { TextField } from './text-field'; import { TextField } from './text-field';
export type SigningPageViewProps = { export type SigningPageViewProps = {
document: DocumentAndSender; document: DocumentAndSender;
recipient: Recipient; recipient: RecipientWithFields;
fields: Field[]; fields: Field[];
completedFields: CompletedField[]; completedFields: CompletedField[];
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
allRecipients?: RecipientWithFields[];
}; };
export const SigningPageView = ({ export const SigningPageView = ({
@ -50,9 +57,12 @@ export const SigningPageView = ({
fields, fields,
completedFields, completedFields,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [],
}: SigningPageViewProps) => { }: SigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const shouldUseTeamDetails = const shouldUseTeamDetails =
document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false; document.teamId && document.team?.teamGlobalSettings?.includeSenderDetails === false;
@ -64,7 +74,10 @@ export const SigningPageView = ({
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : ''; senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
} }
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
return ( return (
<RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
<div className="mx-auto w-full max-w-screen-xl"> <div className="mx-auto w-full max-w-screen-xl">
<h1 <h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl" className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
@ -107,6 +120,15 @@ export const SigningPageView = ({
<Trans>has invited you to approve this document</Trans> <Trans>has invited you to approve this document</Trans>
), ),
) )
.with(RecipientRole.ASSISTANT, () =>
document.teamId && !shouldUseTeamDetails ? (
<Trans>
on behalf of "{document.team?.name}" has invited you to assist this document
</Trans>
) : (
<Trans>has invited you to assist this document</Trans>
),
)
.otherwise(() => null)} .otherwise(() => null)}
</span> </span>
</div> </div>
@ -136,81 +158,78 @@ export const SigningPageView = ({
fields={fields} fields={fields}
redirectUrl={documentMeta?.redirectUrl} redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn} isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId}
/> />
</div> </div>
</div> </div>
<DocumentReadOnlyFields fields={completedFields} /> <DocumentReadOnlyFields fields={completedFields} />
{recipient.role !== RecipientRole.ASSISTANT && (
<AutoSign recipient={recipient} fields={fields} /> <AutoSign recipient={recipient} fields={fields} />
)}
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}> <ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) => {fields
.filter(
(field) =>
recipient.role !== RecipientRole.ASSISTANT ||
field.recipientId === selectedSigner?.id,
)
.map((field) =>
match(field.type) match(field.type)
.with(FieldType.SIGNATURE, () => ( .with(FieldType.SIGNATURE, () => <SignatureField key={field.id} field={field} />)
<SignatureField .with(FieldType.INITIALS, () => <InitialsField key={field.id} field={field} />)
key={field.id} .with(FieldType.NAME, () => <NameField key={field.id} field={field} />)
field={field}
recipient={recipient}
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (
<InitialsField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.NAME, () => (
<NameField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.DATE, () => ( .with(FieldType.DATE, () => (
<DateField <DateField
key={field.id} key={field.id}
field={field} field={field}
recipient={recipient}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT} dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE} timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/> />
)) ))
.with(FieldType.EMAIL, () => ( .with(FieldType.EMAIL, () => <EmailField key={field.id} field={field} />)
<EmailField key={field.id} field={field} recipient={recipient} />
))
.with(FieldType.TEXT, () => { .with(FieldType.TEXT, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = { const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field, ...field,
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null, fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
}; };
return <TextField key={field.id} field={fieldWithMeta} recipient={recipient} />; return <TextField key={field.id} field={fieldWithMeta} />;
}) })
.with(FieldType.NUMBER, () => { .with(FieldType.NUMBER, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = { const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field, ...field,
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null, fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
}; };
return <NumberField key={field.id} field={fieldWithMeta} recipient={recipient} />; return <NumberField key={field.id} field={fieldWithMeta} />;
}) })
.with(FieldType.RADIO, () => { .with(FieldType.RADIO, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = { const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field, ...field,
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null, fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
}; };
return <RadioField key={field.id} field={fieldWithMeta} recipient={recipient} />; return <RadioField key={field.id} field={fieldWithMeta} />;
}) })
.with(FieldType.CHECKBOX, () => { .with(FieldType.CHECKBOX, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = { const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field, ...field,
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null, fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
}; };
return <CheckboxField key={field.id} field={fieldWithMeta} recipient={recipient} />; return <CheckboxField key={field.id} field={fieldWithMeta} />;
}) })
.with(FieldType.DROPDOWN, () => { .with(FieldType.DROPDOWN, () => {
const fieldWithMeta: FieldWithSignatureAndFieldMeta = { const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
...field, ...field,
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null, fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
}; };
return <DropdownField key={field.id} field={fieldWithMeta} recipient={recipient} />; return <DropdownField key={field.id} field={fieldWithMeta} />;
}) })
.otherwise(() => null), .otherwise(() => null),
)} )}
</ElementVisible> </ElementVisible>
</div> </div>
</RecipientProvider>
); );
}; };

View File

@ -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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { ZTextFieldMeta } from '@documenso/lib/types/field-meta'; 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 type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -27,26 +26,31 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRecipientContext } from './recipient-context';
import { SigningFieldContainer } from './signing-field-container'; import { SigningFieldContainer } from './signing-field-container';
type ValidationErrors = {
required: string[];
characterLimit: string[];
};
export type TextFieldProps = { export type TextFieldProps = {
field: FieldWithSignatureAndFieldMeta; field: FieldWithSignatureAndFieldMeta;
recipient: Recipient;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
}; };
export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => { export const TextField = ({ field, onSignField, onUnsignField }: TextFieldProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { recipient, targetSigner, isAssistantMode } = useRecipientContext();
const router = useRouter(); const router = useRouter();
const initialErrors: Record<string, string[]> = { const initialErrors: ValidationErrors = {
required: [], required: [],
characterLimit: [], characterLimit: [],
}; };
const [errors, setErrors] = useState(initialErrors); const [errors, setErrors] = useState(initialErrors);
const userInputHasErrors = Object.values(errors).some((error) => error.length > 0); const userInputHasErrors = Object.values(errors).some((error) => error.length > 0);
@ -166,7 +170,9 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
toast({ toast({
title: _(msg`Error`), 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', variant: 'destructive',
}); });
} }
@ -194,7 +200,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`An error occurred while removing the text.`), description: _(msg`An error occurred while removing the field.`),
variant: 'destructive', variant: 'destructive',
}); });
} }
@ -234,7 +240,7 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
onPreSign={onPreSign} onPreSign={onPreSign}
onSign={onSign} onSign={onSign}
onRemove={onRemove} onRemove={onRemove}
type="Signature" type="Text"
> >
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">

View File

@ -485,7 +485,6 @@ export const EmbedDirectTemplateClientPage = ({
{/* Fields */} {/* Fields */}
<EmbedDocumentFields <EmbedDocumentFields
recipient={recipient}
fields={localFields} fields={localFields}
metadata={metadata} metadata={metadata}
onSignField={onSignField} onSignField={onSignField}

View File

@ -13,6 +13,7 @@ import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider'; import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider'; import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
import { EmbedAuthenticateView } from '../../authenticate'; import { EmbedAuthenticateView } from '../../authenticate';
import { EmbedPaywall } from '../../paywall'; import { EmbedPaywall } from '../../paywall';
@ -96,6 +97,7 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
recipient={recipient} recipient={recipient}
user={user} user={user}
> >
<RecipientProvider recipient={recipient}>
<EmbedDirectTemplateClientPage <EmbedDirectTemplateClientPage
token={token} token={token}
updatedAt={template.updatedAt} updatedAt={template.updatedAt}
@ -106,6 +108,7 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy} hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument} isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
/> />
</RecipientProvider>
</DocumentAuthProvider> </DocumentAuthProvider>
</SigningProvider> </SigningProvider>
); );

View File

@ -12,7 +12,7 @@ import {
ZRadioFieldMeta, ZRadioFieldMeta,
ZTextFieldMeta, ZTextFieldMeta,
} from '@documenso/lib/types/field-meta'; } 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 Field, FieldType } from '@documenso/prisma/client';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { import type {
@ -33,7 +33,6 @@ import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
import { TextField } from '~/app/(signing)/sign/[token]/text-field'; import { TextField } from '~/app/(signing)/sign/[token]/text-field';
export type EmbedDocumentFieldsProps = { export type EmbedDocumentFieldsProps = {
recipient: Recipient;
fields: Field[]; fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null; metadata?: DocumentMeta | TemplateMeta | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
@ -41,7 +40,6 @@ export type EmbedDocumentFieldsProps = {
}; };
export const EmbedDocumentFields = ({ export const EmbedDocumentFields = ({
recipient,
fields, fields,
metadata, metadata,
onSignField, onSignField,
@ -55,7 +53,6 @@ export const EmbedDocumentFields = ({
<SignatureField <SignatureField
key={field.id} key={field.id}
field={field} field={field}
recipient={recipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
typedSignatureEnabled={metadata?.typedSignatureEnabled} typedSignatureEnabled={metadata?.typedSignatureEnabled}
@ -65,7 +62,6 @@ export const EmbedDocumentFields = ({
<InitialsField <InitialsField
key={field.id} key={field.id}
field={field} field={field}
recipient={recipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -74,7 +70,6 @@ export const EmbedDocumentFields = ({
<NameField <NameField
key={field.id} key={field.id}
field={field} field={field}
recipient={recipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -83,7 +78,6 @@ export const EmbedDocumentFields = ({
<DateField <DateField
key={field.id} key={field.id}
field={field} field={field}
recipient={recipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
dateFormat={metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT} dateFormat={metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
@ -94,7 +88,6 @@ export const EmbedDocumentFields = ({
<EmailField <EmailField
key={field.id} key={field.id}
field={field} field={field}
recipient={recipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -109,7 +102,6 @@ export const EmbedDocumentFields = ({
<TextField <TextField
key={field.id} key={field.id}
field={fieldWithMeta} field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -125,7 +117,6 @@ export const EmbedDocumentFields = ({
<NumberField <NumberField
key={field.id} key={field.id}
field={fieldWithMeta} field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -141,7 +132,6 @@ export const EmbedDocumentFields = ({
<RadioField <RadioField
key={field.id} key={field.id}
field={fieldWithMeta} field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -157,7 +147,6 @@ export const EmbedDocumentFields = ({
<CheckboxField <CheckboxField
key={field.id} key={field.id}
field={fieldWithMeta} field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />
@ -173,7 +162,6 @@ export const EmbedDocumentFields = ({
<DropdownField <DropdownField
key={field.id} key={field.id}
field={fieldWithMeta} field={fieldWithMeta}
recipient={recipient}
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
/> />

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useLayoutEffect, useState } from 'react'; import { useEffect, useId, useLayoutEffect, useState } from 'react';
import { Trans, msg } from '@lingui/macro'; import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; 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 { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client'; import type { DocumentMeta, TemplateMeta } from '@documenso/prisma/client';
import { type DocumentData, type Field, FieldType } 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 { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button'; 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 { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; 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 { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
import { RecipientProvider } from '~/app/(signing)/sign/[token]/recipient-context';
import { Logo } from '~/components/branding/logo'; import { Logo } from '~/components/branding/logo';
import { EmbedClientLoading } from '../../client-loading'; import { EmbedClientLoading } from '../../client-loading';
@ -35,12 +38,13 @@ export type EmbedSignDocumentClientPageProps = {
token: string; token: string;
documentId: number; documentId: number;
documentData: DocumentData; documentData: DocumentData;
recipient: Recipient; recipient: RecipientWithFields;
fields: Field[]; fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null; metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean; isCompleted?: boolean;
hidePoweredBy?: boolean; hidePoweredBy?: boolean;
isPlatformOrEnterprise?: boolean; isPlatformOrEnterprise?: boolean;
allRecipients?: RecipientWithFields[];
}; };
export const EmbedSignDocumentClientPage = ({ export const EmbedSignDocumentClientPage = ({
@ -53,6 +57,7 @@ export const EmbedSignDocumentClientPage = ({
isCompleted, isCompleted,
hidePoweredBy = false, hidePoweredBy = false,
isPlatformOrEnterprise = false, isPlatformOrEnterprise = false,
allRecipients = [],
}: EmbedSignDocumentClientPageProps) => { }: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@ -70,17 +75,21 @@ export const EmbedSignDocumentClientPage = ({
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted); const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
allRecipients.length > 0 ? allRecipients[0].id : null,
);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false); const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = 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 [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [pendingFields, _completedFields] = [ const [pendingFields, _completedFields] = [
fields.filter((field) => !field.inserted), fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
fields.filter((field) => field.inserted), fields.filter((field) => field.inserted),
]; ];
@ -89,6 +98,8 @@ export const EmbedSignDocumentClientPage = ({
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const assistantSignersId = useId();
const onNextFieldClick = () => { const onNextFieldClick = () => {
validateFieldsInserted(fields); validateFieldsInserted(fields);
@ -214,6 +225,7 @@ export const EmbedSignDocumentClientPage = ({
} }
return ( return (
<RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6"> <div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />} {(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
@ -237,7 +249,11 @@ export const EmbedSignDocumentClientPage = ({
<div> <div>
<div className="flex items-center justify-between gap-x-2"> <div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl"> <h3 className="text-foreground text-xl font-semibold md:text-2xl">
{isAssistantMode ? (
<Trans>Assist with signing</Trans>
) : (
<Trans>Sign document</Trans> <Trans>Sign document</Trans>
)}
</h3> </h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden"> <Button variant="outline" className="h-8 w-8 p-0 md:hidden">
@ -258,7 +274,11 @@ export const EmbedSignDocumentClientPage = ({
<div className="hidden group-data-[expanded]/document-widget:block md:block"> <div className="hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
{isAssistantMode ? (
<Trans>Help complete the document for other signers.</Trans>
) : (
<Trans>Sign the document to complete the process.</Trans> <Trans>Sign the document to complete the process.</Trans>
)}
</p> </p>
<hr className="border-border mb-8 mt-4" /> <hr className="border-border mb-8 mt-4" />
@ -267,6 +287,62 @@ export const EmbedSignDocumentClientPage = ({
{/* Form */} {/* Form */}
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block"> <div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
<div className="flex flex-1 flex-col gap-y-4"> <div className="flex flex-1 flex-col gap-y-4">
{isAssistantMode && (
<div>
<Label>
<Trans>Signing for</Trans>
</Label>
<fieldset className="dark:bg-background border-border mt-2 rounded-2xl border bg-white p-3">
<RadioGroup
className="gap-0 space-y-3 shadow-none"
value={selectedSignerId?.toString()}
onValueChange={(value) => setSelectedSignerId(Number(value))}
>
{allRecipients
.filter((r) => r.fields.length > 0)
.map((r) => (
<div
key={`${assistantSignersId}-${r.id}`}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<RadioGroupItem
id={`${assistantSignersId}-${r.id}`}
value={r.id.toString()}
className="after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label
className="inline-flex items-start"
htmlFor={`${assistantSignersId}-${r.id}`}
>
{r.name}
{r.id === recipient.id && (
<span className="text-muted-foreground ml-2">
{_(msg`(You)`)}
</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
</div>
</div>
</div>
))}
</RadioGroup>
</fieldset>
</div>
)}
{!isAssistantMode && (
<>
<div> <div>
<Label htmlFor="full-name"> <Label htmlFor="full-name">
<Trans>Full Name</Trans> <Trans>Full Name</Trans>
@ -330,6 +406,8 @@ export const EmbedSignDocumentClientPage = ({
</div> </div>
)} )}
</div> </div>
</>
)}
</div> </div>
</div> </div>
@ -343,7 +421,9 @@ export const EmbedSignDocumentClientPage = ({
) : ( ) : (
<Button <Button
className="col-start-2" className="col-start-2"
disabled={isThrottled || (hasSignatureField && !signatureValid)} disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
loading={isSubmitting} loading={isSubmitting}
onClick={() => throttledOnCompleteClick()} onClick={() => throttledOnCompleteClick()}
> >
@ -363,7 +443,7 @@ export const EmbedSignDocumentClientPage = ({
</ElementVisible> </ElementVisible>
{/* Fields */} {/* Fields */}
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} /> <EmbedDocumentFields fields={fields} metadata={metadata} />
</div> </div>
{!hidePoweredBy && ( {!hidePoweredBy && (
@ -373,5 +453,6 @@ export const EmbedSignDocumentClientPage = ({
</div> </div>
)} )}
</div> </div>
</RecipientProvider>
); );
}; };

View File

@ -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 { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-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 { 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 { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/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 { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider'; import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
import { EmbedAuthenticateView } from '../../authenticate'; import { EmbedAuthenticateView } from '../../authenticate';
import { EmbedPaywall } from '../../paywall'; import { EmbedPaywall } from '../../paywall';
import { EmbedWaitingForTurn } from '../../waiting-for-turn';
import { EmbedSignDocumentClientPage } from './client'; import { EmbedSignDocumentClientPage } from './client';
export type EmbedSignDocumentPageProps = { export type EmbedSignDocumentPageProps = {
@ -85,6 +88,19 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
); );
} }
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurnToSign) {
return <EmbedWaitingForTurn />;
}
const allRecipients =
recipient.role === RecipientRole.ASSISTANT
? await getRecipientsForAssistant({
token,
})
: [];
const team = document.teamId const team = document.teamId
? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null) ? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null)
: null; : null;
@ -112,6 +128,7 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
isCompleted={document.status === DocumentStatus.COMPLETED} isCompleted={document.status === DocumentStatus.COMPLETED}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy} hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument} isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
allRecipients={allRecipients}
/> />
</DocumentAuthProvider> </DocumentAuthProvider>
</SigningProvider> </SigningProvider>

View File

@ -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 (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-center text-2xl font-bold">
<Trans>Waiting for Your Turn</Trans>
</h3>
<div className="mt-8 max-w-[50ch] text-center">
<p className="text-muted-foreground text-sm">
<Trans>
It's currently not your turn to sign. Please check back soon as this document should be
available for you to sign shortly.
</Trans>
</p>
<p className="text-muted-foreground mt-4 text-sm">
<Trans>Please check with the parent application for more information.</Trans>
</p>
</div>
</div>
);
};

View File

@ -85,7 +85,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [pages, setPages] = useState<string[]>([]); const [pages, setPages] = useState<string[]>([]);
const { data: searchDocumentsData, isLoading: isSearchingDocuments } = const { data: searchDocumentsData, isPending: isSearchingDocuments } =
trpcReact.document.searchDocuments.useQuery( trpcReact.document.searchDocuments.useQuery(
{ {
query: search, query: search,

View File

@ -67,7 +67,7 @@ export const TransferTeamDialog = ({
const { const {
data, data,
refetch: refetchTeamMembers, refetch: refetchTeamMembers,
isLoading: loadingTeamMembers, isPending: loadingTeamMembers,
isLoadingError: loadingTeamMembersError, isLoadingError: loadingTeamMembersError,
} = trpc.team.getTeamMembers.useQuery({ } = trpc.team.getTeamMembers.useQuery({
teamId, teamId,

View File

@ -353,6 +353,16 @@ export const DocumentHistorySheet = ({
/> />
), ),
) )
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Field prefilled',
value: formatGenericText(data.field.type),
},
]}
/>
))
.exhaustive()} .exhaustive()}
{isUserDetailsVisible && ( {isUserDetailsVisible && (

View File

@ -540,12 +540,19 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
if (i > 1) { if (i > 1) {
await page.getByRole('button', { name: 'Add Signer' }).click(); await page.getByRole('button', { name: 'Add Signer' }).click();
} }
await page await page
.getByPlaceholder('Email') .getByLabel('Email')
.nth(i - 1)
.focus();
await page
.getByLabel('Email')
.nth(i - 1) .nth(i - 1)
.fill(`user${i}@example.com`); .fill(`user${i}@example.com`);
await page await page
.getByPlaceholder('Name') .getByLabel('Name')
.nth(i - 1) .nth(i - 1)
.fill(`User ${i}`); .fill(`User ${i}`);
} }

View File

@ -84,6 +84,9 @@ export const TemplateDocumentInvite = ({
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>) .with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>) .with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>)
.with(RecipientRole.CC, () => '') .with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => (
<Trans>Continue by assisting with the document.</Trans>
))
.exhaustive()} .exhaustive()}
</Text> </Text>
@ -104,6 +107,7 @@ export const TemplateDocumentInvite = ({
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>) .with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>) .with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.CC, () => '') .with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.exhaustive()} .exhaustive()}
</Button> </Button>
</Section> </Section>

View File

@ -10,6 +10,9 @@ export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: { [DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: {
description: 'Approval request', description: 'Approval request',
}, },
[DOCUMENT_EMAIL_TYPE.ASSISTING_REQUEST]: {
description: 'Assisting request',
},
[DOCUMENT_EMAIL_TYPE.CC]: { [DOCUMENT_EMAIL_TYPE.CC]: {
description: 'CC', description: 'CC',
}, },

View File

@ -32,12 +32,26 @@ export const RECIPIENT_ROLES_DESCRIPTION = {
roleName: msg`Viewer`, roleName: msg`Viewer`,
roleNamePlural: msg`Viewers`, roleNamePlural: msg`Viewers`,
}, },
[RecipientRole.ASSISTANT]: {
actionVerb: msg`Assist`,
actioned: msg`Assisted`,
progressiveVerb: msg`Assisting`,
roleName: msg`Assistant`,
roleNamePlural: msg`Assistants`,
},
} satisfies Record<keyof typeof RecipientRole, unknown>; } satisfies Record<keyof typeof RecipientRole, unknown>;
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 = { export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: `SIGNING_REQUEST`, [RecipientRole.SIGNER]: `SIGNING_REQUEST`,
[RecipientRole.VIEWER]: `VIEW_REQUEST`, [RecipientRole.VIEWER]: `VIEW_REQUEST`,
[RecipientRole.APPROVER]: `APPROVE_REQUEST`, [RecipientRole.APPROVER]: `APPROVE_REQUEST`,
[RecipientRole.ASSISTANT]: `ASSISTING_REQUEST`,
} as const; } as const;
export const RECIPIENT_ROLE_SIGNING_REASONS = { 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.APPROVER]: msg`I am an approver of this document`,
[RecipientRole.CC]: msg`I am required to receive a copy 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.VIEWER]: msg`I am a viewer of this document`,
[RecipientRole.ASSISTANT]: msg`I am an assistant of this document`,
} satisfies Record<keyof typeof RecipientRole, MessageDescriptor>; } satisfies Record<keyof typeof RecipientRole, MessageDescriptor>;

View File

@ -14,8 +14,8 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } 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 { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';

View File

@ -1,15 +1,55 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
export type GetFieldsForTokenOptions = { export type GetFieldsForTokenOptions = {
token: string; token: string;
}; };
export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => { 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({ return await prisma.field.findMany({
where: { where: {
recipient: { OR: [
token, {
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: {
recipientId: recipient.id,
}, },
include: { include: {
signature: true, signature: true,

View File

@ -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 type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
export type RemovedSignedFieldWithTokenOptions = { export type RemovedSignedFieldWithTokenOptions = {
token: string; token: string;
@ -17,11 +17,28 @@ export const removeSignedFieldWithToken = async ({
fieldId, fieldId,
requestMetadata, requestMetadata,
}: RemovedSignedFieldWithTokenOptions) => { }: RemovedSignedFieldWithTokenOptions) => {
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
token,
},
});
const field = await prisma.field.findFirstOrThrow({ const field = await prisma.field.findFirstOrThrow({
where: { where: {
id: fieldId, id: fieldId,
recipient: { recipient: {
token, ...(recipient.role !== RecipientRole.ASSISTANT
? {
id: recipient.id,
}
: {
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
signingStatus: {
not: SigningStatus.SIGNED,
},
}),
}, },
}, },
include: { include: {
@ -30,7 +47,7 @@ export const removeSignedFieldWithToken = async ({
}, },
}); });
const { document, recipient } = field; const { document } = field;
if (!document) { if (!document) {
throw new Error(`Document not found for field ${field.id}`); 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`); 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`); throw new Error(`Recipient ${recipient.id} has already signed`);
} }
@ -66,13 +86,14 @@ export const removeSignedFieldWithToken = async ({
}, },
}); });
if (recipient.role !== RecipientRole.ASSISTANT) {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED, type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
documentId: document.id, documentId: document.id,
user: { user: {
name: recipient?.name, name: recipient.name,
email: recipient?.email, email: recipient.email,
}, },
requestMetadata, requestMetadata,
data: { data: {
@ -81,5 +102,6 @@ export const removeSignedFieldWithToken = async ({
}, },
}), }),
}); });
}
}); });
}; };

View File

@ -10,7 +10,7 @@ import { validateRadioField } from '@documenso/lib/advanced-fields-validation/va
import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text'; import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text';
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox'; import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
import { prisma } from '@documenso/prisma'; 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_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
@ -56,20 +56,41 @@ export const signFieldWithToken = async ({
authOptions, authOptions,
requestMetadata, requestMetadata,
}: SignFieldWithTokenOptions) => { }: SignFieldWithTokenOptions) => {
const recipient = await prisma.recipient.findFirstOrThrow({
where: {
token,
},
});
const field = await prisma.field.findFirstOrThrow({ const field = await prisma.field.findFirstOrThrow({
where: { where: {
id: fieldId, id: fieldId,
recipient: { recipient: {
token, ...(recipient.role !== RecipientRole.ASSISTANT
? {
id: recipient.id,
}
: {
signingStatus: {
not: SigningStatus.SIGNED,
},
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
}),
}, },
}, },
include: { include: {
document: true, document: {
include: {
recipients: true,
},
},
recipient: true, recipient: true,
}, },
}); });
const { document, recipient } = field; const { document } = field;
if (!document) { if (!document) {
throw new Error(`Document not found for field ${field.id}`); 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`); 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`); 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'); 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) => { return await prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({ const updatedField = await tx.field.update({
where: { where: {
@ -219,11 +245,14 @@ export const signFieldWithToken = async ({
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({
data: createDocumentAuditLogData({ 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, documentId: document.id,
user: { user: {
email: recipient.email, email: assistant?.email ?? recipient.email,
name: recipient.name, name: assistant?.name ?? recipient.name,
}, },
requestMetadata, requestMetadata,
data: { data: {

View File

@ -9,5 +9,8 @@ export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions)
where: { where: {
token, token,
}, },
include: {
fields: true,
},
}); });
}; };

View File

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

View File

@ -28,6 +28,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_DELETED', // When the document is soft deleted. 'DOCUMENT_DELETED', // When the document is soft deleted.
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient. '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_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_VISIBILITY_UPDATED', // When the document visibility scope is updated
'DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED', // When the global access authentication 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. 'DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED', // When the global action authentication is updated.
@ -45,6 +46,7 @@ export const ZDocumentAuditLogEmailTypeSchema = z.enum([
'SIGNING_REQUEST', 'SIGNING_REQUEST',
'VIEW_REQUEST', 'VIEW_REQUEST',
'APPROVE_REQUEST', 'APPROVE_REQUEST',
'ASSISTING_REQUEST',
'CC', 'CC',
'DOCUMENT_COMPLETED', '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({ export const ZDocumentAuditLogEventDocumentVisibilitySchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED), type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED),
data: ZGenericFromToSchema, data: ZGenericFromToSchema,
@ -493,6 +572,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentMovedToTeamSchema, ZDocumentAuditLogEventDocumentMovedToTeamSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema, ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema, ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentFieldPrefilledSchema,
ZDocumentAuditLogEventDocumentVisibilitySchema, ZDocumentAuditLogEventDocumentVisibilitySchema,
ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema, ZDocumentAuditLogEventDocumentGlobalAuthAccessUpdatedSchema,
ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema, ZDocumentAuditLogEventDocumentGlobalAuthActionUpdatedSchema,

View File

@ -314,6 +314,10 @@ export const formatDocumentAuditLogAction = (
anonymous: msg`Field unsigned`, anonymous: msg`Field unsigned`,
identified: msg`${prefix} unsigned a field`, 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 }, () => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
anonymous: msg`Document visibility updated`, anonymous: msg`Document visibility updated`,
identified: msg`${prefix} updated the document visibility`, identified: msg`${prefix} updated the document visibility`,

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "RecipientRole" ADD VALUE 'ASSISTANT';

View File

@ -417,6 +417,7 @@ enum RecipientRole {
SIGNER SIGNER
VIEWER VIEWER
APPROVER APPROVER
ASSISTANT
} }
/// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"]) /// @zod.import(["import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])

View File

@ -0,0 +1,5 @@
import type { Field, Recipient } from '@documenso/prisma/client';
export type RecipientWithFields = Recipient & {
fields: Field[];
};

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { forwardRef } from 'react'; import { forwardRef } from 'react';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import type { SelectProps } from '@radix-ui/react-select'; 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 { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { cn } from '../../lib/utils';
export type RecipientRoleSelectProps = SelectProps & { export type RecipientRoleSelectProps = SelectProps & {
hideCCRecipients?: boolean; hideCCRecipients?: boolean;
isAssistantEnabled?: boolean;
}; };
export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSelectProps>( export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSelectProps>(
({ hideCCRecipients, ...props }, ref) => ( ({ hideCCRecipients, isAssistantEnabled = true, ...props }, ref) => (
<Select {...props}> <Select {...props}>
<SelectTrigger ref={ref} className="bg-background w-[50px] p-2"> <SelectTrigger ref={ref} className="bg-background w-[50px] p-2">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */} {/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
@ -110,6 +113,42 @@ export const RecipientRoleSelect = forwardRef<HTMLButtonElement, RecipientRoleSe
</div> </div>
</SelectItem> </SelectItem>
)} )}
<SelectItem
value={RecipientRole.ASSISTANT}
disabled={!isAssistantEnabled}
className={cn(
!isAssistantEnabled &&
'cursor-not-allowed opacity-50 data-[disabled]:pointer-events-auto',
)}
>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.ASSISTANT]}</span>
<Trans>Can prepare</Trans>
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<p>
{isAssistantEnabled ? (
<Trans>
The recipient can prepare the document for later signers by pre-filling
suggest values.
</Trans>
) : (
<Trans>
Assistant role is only available when the document is in sequential signing
mode.
</Trans>
)}
</p>
</TooltipContent>
</Tooltip>
</div>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
), ),

View File

@ -508,7 +508,15 @@ export const AddFieldsFormPartial = ({
}, []); }, []);
useEffect(() => { 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]); }, [recipients]);
const recipientsByRole = useMemo(() => { const recipientsByRole = useMemo(() => {
@ -517,6 +525,7 @@ export const AddFieldsFormPartial = ({
VIEWER: [], VIEWER: [],
SIGNER: [], SIGNER: [],
APPROVER: [], APPROVER: [],
ASSISTANT: [],
}; };
recipients.forEach((recipient) => { recipients.forEach((recipient) => {
@ -529,7 +538,12 @@ export const AddFieldsFormPartial = ({
const recipientsByRoleToDisplay = useMemo(() => { const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]) 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( .map(
([role, roleRecipients]) => ([role, roleRecipients]) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@ -544,12 +558,6 @@ export const AddFieldsFormPartial = ({
); );
}, [recipientsByRole]); }, [recipientsByRole]);
const isTypedSignatureEnabled = form.watch('typedSignatureEnabled');
const handleTypedSignatureChange = (value: boolean) => {
form.setValue('typedSignatureEnabled', value, { shouldDirty: true });
};
const handleAdvancedSettings = () => { const handleAdvancedSettings = () => {
setShowAdvancedSettings((prev) => !prev); setShowAdvancedSettings((prev) => !prev);
}; };
@ -687,9 +695,7 @@ export const AddFieldsFormPartial = ({
)} )}
{!selectedSigner?.email && ( {!selectedSigner?.email && (
<span className="gradie flex-1 truncate text-left"> <span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
{selectedSigner?.email}
</span>
)} )}
<ChevronsUpDown className="ml-2 h-4 w-4" /> <ChevronsUpDown className="ml-2 h-4 w-4" />

View File

@ -41,6 +41,7 @@ import {
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import { ShowFieldItem } from './show-field-item'; import { ShowFieldItem } from './show-field-item';
import { SigningOrderConfirmation } from './signing-order-confirmation';
import type { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
export type AddSignersFormProps = { export type AddSignersFormProps = {
@ -123,6 +124,7 @@ export const AddSignersFormPartial = ({
}, [recipients, form]); }, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings); const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
const [showSigningOrderConfirmation, setShowSigningOrderConfirmation] = useState(false);
const { const {
setValue, setValue,
@ -134,6 +136,10 @@ export const AddSignersFormPartial = ({
const watchedSigners = watch('signers'); const watchedSigners = watch('signers');
const isSigningOrderSequential = watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL; const isSigningOrderSequential = watch('signingOrder') === DocumentSigningOrder.SEQUENTIAL;
const hasAssistantRole = useMemo(() => {
return watchedSigners.some((signer) => signer.role === RecipientRole.ASSISTANT);
}, [watchedSigners]);
const normalizeSigningOrders = (signers: typeof watchedSigners) => { const normalizeSigningOrders = (signers: typeof watchedSigners) => {
return signers return signers
.sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0)) .sort((a, b) => (a.signingOrder ?? 0) - (b.signingOrder ?? 0))
@ -233,6 +239,7 @@ export const AddSignersFormPartial = ({
const items = Array.from(watchedSigners); const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1); const [reorderedSigner] = items.splice(result.source.index, 1);
// Find next valid position
let insertIndex = result.destination.index; let insertIndex = result.destination.index;
while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].nativeId)) { while (insertIndex < items.length && !canRecipientBeModified(items[insertIndex].nativeId)) {
insertIndex++; insertIndex++;
@ -240,126 +247,116 @@ export const AddSignersFormPartial = ({
items.splice(insertIndex, 0, reorderedSigner); items.splice(insertIndex, 0, reorderedSigner);
const updatedSigners = items.map((item, index) => ({ const updatedSigners = items.map((signer, index) => ({
...item, ...signer,
signingOrder: !canRecipientBeModified(item.nativeId) ? item.signingOrder : index + 1, signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : index + 1,
})); }));
updatedSigners.forEach((item, index) => { form.setValue('signers', updatedSigners);
const keys: (keyof typeof item)[] = [
'formId',
'nativeId',
'email',
'name',
'role',
'signingOrder',
'actionAuth',
];
keys.forEach((key) => {
form.setValue(`signers.${index}.${key}` as const, item[key]);
});
});
const currentLength = form.getValues('signers').length; const lastSigner = updatedSigners[updatedSigners.length - 1];
if (currentLength > updatedSigners.length) { if (lastSigner.role === RecipientRole.ASSISTANT) {
for (let i = updatedSigners.length; i < currentLength; i++) { toast({
form.unregister(`signers.${i}`); 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'); await form.trigger('signers');
}, },
[form, canRecipientBeModified, watchedSigners], [form, canRecipientBeModified, watchedSigners, toast],
); );
const triggerDragAndDrop = useCallback( const handleRoleChange = useCallback(
(fromIndex: number, toIndex: number) => { (index: number, role: RecipientRole) => {
if (!$sensorApi.current) { 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; return;
} }
const draggableId = signers[fromIndex].id; const updatedSigners = currentSigners.map((signer, idx) => ({
const preDrag = $sensorApi.current.tryGetLock(draggableId);
if (!preDrag) {
return;
}
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, ...signer,
signingOrder: !canRecipientBeModified(signer.nativeId) role: idx === index ? role : signer.role,
? signer.signingOrder signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
: (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', updatedSigners);
form.setValue(`signers.${index}.signingOrder`, signer.signingOrder);
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, canRecipientBeModified], [form, toast, canRecipientBeModified],
); );
const handleSigningOrderChange = useCallback( const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => { (index: number, newOrderString: string) => {
const newOrder = parseInt(newOrderString, 10); const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
if (!newOrderString.trim()) {
return; return;
} }
if (Number.isNaN(newOrder)) { const newOrder = Number(trimmedOrderString);
form.setValue(`signers.${index}.signingOrder`, index + 1); if (!Number.isInteger(newOrder) || newOrder < 1) {
return; return;
} }
const newIndex = newOrder - 1; const currentSigners = form.getValues('signers');
if (index !== newIndex) { const signer = currentSigners[index];
updateSigningOrders(newIndex, index);
triggerDragAndDrop(index, newIndex); // 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 ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader
@ -384,11 +381,16 @@ export const AddSignersFormPartial = ({
{...field} {...field}
id="signingOrder" id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL} checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) => onCheckedChange={(checked) => {
if (!checked && hasAssistantRole) {
setShowSigningOrderConfirmation(true);
return;
}
field.onChange( field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL, checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
) );
} }}
disabled={isSubmitting || hasDocumentBeenSent} disabled={isSubmitting || hasDocumentBeenSent}
/> />
</FormControl> </FormControl>
@ -613,7 +615,11 @@ export const AddSignersFormPartial = ({
<FormControl> <FormControl>
<RecipientRoleSelect <RecipientRoleSelect
{...field} {...field}
onValueChange={field.onChange} isAssistantEnabled={isSigningOrderSequential}
onValueChange={(value) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole)
}
disabled={ disabled={
snapshot.isDragging || snapshot.isDragging ||
isSubmitting || isSubmitting ||
@ -710,6 +716,12 @@ export const AddSignersFormPartial = ({
)} )}
</Form> </Form>
</AnimateGenericFadeInOut> </AnimateGenericFadeInOut>
<SigningOrderConfirmation
open={showSigningOrderConfirmation}
onOpenChange={setShowSigningOrderConfirmation}
onConfirm={handleSigningOrderDisable}
/>
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter> <DocumentFlowFormContainerFooter>

View File

@ -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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Warning</AlertDialogTitle>
<AlertDialogDescription>
You have an assistant role on the signers list, removing the signing order will change
the assistant role to signer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Proceed</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -19,18 +19,18 @@ RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef< const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>, React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, children: _children, ...props }, ref) => { >(({ className, ...props }, ref) => {
return ( return (
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
'border-input ring-offset-background focus:ring-ring h-4 w-4 rounded-full border focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', 'border-primary text-primary focus-visible:ring-ring aspect-square h-4 w-4 rounded-full border shadow focus:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
{...props} {...props}
> >
<RadioGroupPrimitive.Indicator className="flex items-center justify-center"> <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="fill-primary text-primary h-2.5 w-2.5" /> <Circle className="fill-primary h-2.5 w-2.5" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
); );

View File

@ -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'; import type { RecipientRole } from '.prisma/client';
@ -7,4 +7,5 @@ export const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
APPROVER: <BadgeCheck className="h-4 w-4" />, APPROVER: <BadgeCheck className="h-4 w-4" />,
CC: <Copy className="h-4 w-4" />, CC: <Copy className="h-4 w-4" />,
VIEWER: <Eye className="h-4 w-4" />, VIEWER: <Eye className="h-4 w-4" />,
ASSISTANT: <User className="h-4 w-4" />,
}; };

View File

@ -32,7 +32,7 @@ import {
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n'; import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType, RecipientRole } from '@documenso/prisma/client'; import { FieldType, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -438,6 +438,7 @@ export const AddTemplateFieldsFormPartial = ({
VIEWER: [], VIEWER: [],
SIGNER: [], SIGNER: [],
APPROVER: [], APPROVER: [],
ASSISTANT: [],
}; };
recipients.forEach((recipient) => { recipients.forEach((recipient) => {
@ -447,10 +448,25 @@ export const AddTemplateFieldsFormPartial = ({
return recipientsByRole; return recipientsByRole;
}, [recipients]); }, [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(() => { const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][]).filter( 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]); }, [recipientsByRole]);

View File

@ -29,6 +29,7 @@ import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { toast } from '@documenso/ui/primitives/use-toast';
import { Checkbox } from '../checkbox'; import { Checkbox } from '../checkbox';
import { import {
@ -39,6 +40,7 @@ import {
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root'; } from '../document-flow/document-flow-root';
import { ShowFieldItem } from '../document-flow/show-field-item'; import { ShowFieldItem } from '../document-flow/show-field-item';
import { SigningOrderConfirmation } from '../document-flow/signing-order-confirmation';
import type { DocumentFlowStep } from '../document-flow/types'; import type { DocumentFlowStep } from '../document-flow/types';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
@ -213,41 +215,30 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const items = Array.from(watchedSigners); const items = Array.from(watchedSigners);
const [reorderedSigner] = items.splice(result.source.index, 1); const [reorderedSigner] = items.splice(result.source.index, 1);
const insertIndex = result.destination.index; const insertIndex = result.destination.index;
items.splice(insertIndex, 0, reorderedSigner); items.splice(insertIndex, 0, reorderedSigner);
const updatedSigners = items.map((item, index) => ({ const updatedSigners = items.map((signer, index) => ({
...item, ...signer,
signingOrder: index + 1, signingOrder: index + 1,
})); }));
updatedSigners.forEach((item, index) => { form.setValue('signers', updatedSigners);
const keys: (keyof typeof item)[] = [
'formId',
'nativeId',
'email',
'name',
'role',
'signingOrder',
'actionAuth',
];
keys.forEach((key) => {
form.setValue(`signers.${index}.${key}` as const, item[key]);
});
});
const currentLength = form.getValues('signers').length; const lastSigner = updatedSigners[updatedSigners.length - 1];
if (currentLength > updatedSigners.length) { if (lastSigner.role === RecipientRole.ASSISTANT) {
for (let i = updatedSigners.length; i < currentLength; i++) { toast({
form.unregister(`signers.${i}`); 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'); await form.trigger('signers');
}, },
[form, watchedSigners], [form, watchedSigners, toast],
); );
const triggerDragAndDrop = useCallback( const triggerDragAndDrop = useCallback(
@ -308,26 +299,94 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const handleSigningOrderChange = useCallback( const handleSigningOrderChange = useCallback(
(index: number, newOrderString: string) => { (index: number, newOrderString: string) => {
const newOrder = parseInt(newOrderString, 10); const trimmedOrderString = newOrderString.trim();
if (!trimmedOrderString) {
if (!newOrderString.trim()) {
return; return;
} }
if (Number.isNaN(newOrder)) { const newOrder = Number(trimmedOrderString);
form.setValue(`signers.${index}.signingOrder`, index + 1); if (!Number.isInteger(newOrder) || newOrder < 1) {
return; return;
} }
const newIndex = newOrder - 1; const currentSigners = form.getValues('signers');
if (index !== newIndex) { const signer = currentSigners[index];
updateSigningOrders(newIndex, index);
triggerDragAndDrop(index, newIndex); // 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 ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader
@ -353,11 +412,19 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{...field} {...field}
id="signingOrder" id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL} checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) => onCheckedChange={(checked) => {
if (
!checked &&
watchedSigners.some((s) => s.role === RecipientRole.ASSISTANT)
) {
setShowSigningOrderConfirmation(true);
return;
}
field.onChange( field.onChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL, checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
) );
} }}
disabled={isSubmitting} disabled={isSubmitting}
/> />
</FormControl> </FormControl>
@ -556,7 +623,10 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
<FormControl> <FormControl>
<RecipientRoleSelect <RecipientRoleSelect
{...field} {...field}
onValueChange={field.onChange} onValueChange={(value) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
handleRoleChange(index, value as RecipientRole)
}
disabled={isSubmitting} disabled={isSubmitting}
hideCCRecipients={isSignerDirectRecipient(signer)} hideCCRecipients={isSignerDirectRecipient(signer)}
/> />
@ -677,6 +747,12 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
onGoNextClick={() => void onFormSubmit()} onGoNextClick={() => void onFormSubmit()}
/> />
</DocumentFlowFormContainerFooter> </DocumentFlowFormContainerFooter>
<SigningOrderConfirmation
open={showSigningOrderConfirmation}
onOpenChange={setShowSigningOrderConfirmation}
onConfirm={handleSigningOrderDisable}
/>
</> </>
); );
}; };