mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
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:
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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: () => {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,91 +218,185 @@ 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>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<Trans>Please review the document before signing.</Trans>
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
</p>
|
<Trans>Please review the document before signing.</Trans>
|
||||||
|
</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
|
||||||
<div className="flex flex-1 flex-col gap-y-4">
|
disabled={isSubmitting}
|
||||||
<div>
|
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
|
||||||
<Label htmlFor="full-name">
|
>
|
||||||
<Trans>Full Name</Trans>
|
<div className="flex flex-1 flex-col gap-y-4">
|
||||||
</Label>
|
<div>
|
||||||
|
<Label htmlFor="full-name">
|
||||||
|
<Trans>Full Name</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
id="full-name"
|
id="full-name"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="Signature">
|
||||||
|
<Trans>Signature</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<SignaturePad
|
||||||
|
className="h-44 w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
defaultValue={signature ?? undefined}
|
||||||
|
onValidityChange={(isValid) => {
|
||||||
|
setSignatureValid(isValid);
|
||||||
|
}}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (signatureValid) {
|
||||||
|
setSignature(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{hasSignatureField && !signatureValid && (
|
||||||
|
<div className="text-destructive mt-2 text-sm">
|
||||||
|
<Trans>
|
||||||
|
Signature is too small. Please provide a more complete signature.
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||||
|
onClick={() => router.back()}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SignDialog
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||||
|
documentTitle={document.title}
|
||||||
|
fields={fields}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
|
role={recipient.role}
|
||||||
|
disabled={!isRecipientsTurn}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
<div>
|
</form>
|
||||||
<Label htmlFor="Signature">
|
|
||||||
<Trans>Signature</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<SignaturePad
|
|
||||||
className="h-44 w-full"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
defaultValue={signature ?? undefined}
|
|
||||||
onValidityChange={(isValid) => {
|
|
||||||
setSignatureValid(isValid);
|
|
||||||
}}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (signatureValid) {
|
|
||||||
setSignature(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
|
||||||
<div className="text-destructive mt-2 text-sm">
|
|
||||||
<Trans>
|
|
||||||
Signature is too small. Please provide a more complete signature.
|
|
||||||
</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
|
||||||
onClick={() => router.back()}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<SignDialog
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
|
||||||
documentTitle={document.title}
|
|
||||||
fields={fields}
|
|
||||||
fieldsValidated={fieldsValidated}
|
|
||||||
role={recipient.role}
|
|
||||||
disabled={!isRecipientsTurn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -46,6 +46,7 @@ export type SignatureFieldProps = {
|
|||||||
| 'Email'
|
| 'Email'
|
||||||
| 'Name'
|
| 'Name'
|
||||||
| 'Signature'
|
| 'Signature'
|
||||||
|
| 'Text'
|
||||||
| 'Radio'
|
| 'Radio'
|
||||||
| 'Dropdown'
|
| 'Dropdown'
|
||||||
| 'Number'
|
| 'Number'
|
||||||
|
|||||||
@ -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,153 +74,162 @@ 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 (
|
||||||
<div className="mx-auto w-full max-w-screen-xl">
|
<RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
||||||
<h1
|
<div className="mx-auto w-full max-w-screen-xl">
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
<h1
|
||||||
title={document.title}
|
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||||
>
|
title={document.title}
|
||||||
{document.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
|
||||||
<div className="max-w-[50ch]">
|
|
||||||
<span className="text-muted-foreground truncate" title={senderName}>
|
|
||||||
{senderName} {senderEmail}
|
|
||||||
</span>{' '}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{match(recipient.role)
|
|
||||||
.with(RecipientRole.VIEWER, () =>
|
|
||||||
document.teamId && !shouldUseTeamDetails ? (
|
|
||||||
<Trans>
|
|
||||||
on behalf of "{document.team?.name}" has invited you to view this document
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>has invited you to view this document</Trans>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with(RecipientRole.SIGNER, () =>
|
|
||||||
document.teamId && !shouldUseTeamDetails ? (
|
|
||||||
<Trans>
|
|
||||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>has invited you to sign this document</Trans>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with(RecipientRole.APPROVER, () =>
|
|
||||||
document.teamId && !shouldUseTeamDetails ? (
|
|
||||||
<Trans>
|
|
||||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>has invited you to approve this document</Trans>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.otherwise(() => null)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RejectDocumentDialog document={document} token={recipient.token} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
|
||||||
<Card
|
|
||||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
|
||||||
gradient
|
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
{document.title}
|
||||||
<LazyPDFViewer
|
</h1>
|
||||||
key={documentData.id}
|
|
||||||
documentData={documentData}
|
|
||||||
document={document}
|
|
||||||
password={documentMeta?.password}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
||||||
<SigningForm
|
<div className="max-w-[50ch]">
|
||||||
document={document}
|
<span className="text-muted-foreground truncate" title={senderName}>
|
||||||
recipient={recipient}
|
{senderName} {senderEmail}
|
||||||
fields={fields}
|
</span>{' '}
|
||||||
redirectUrl={documentMeta?.redirectUrl}
|
<span className="text-muted-foreground">
|
||||||
isRecipientsTurn={isRecipientsTurn}
|
{match(recipient.role)
|
||||||
/>
|
.with(RecipientRole.VIEWER, () =>
|
||||||
|
document.teamId && !shouldUseTeamDetails ? (
|
||||||
|
<Trans>
|
||||||
|
on behalf of "{document.team?.name}" has invited you to view this document
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>has invited you to view this document</Trans>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with(RecipientRole.SIGNER, () =>
|
||||||
|
document.teamId && !shouldUseTeamDetails ? (
|
||||||
|
<Trans>
|
||||||
|
on behalf of "{document.team?.name}" has invited you to sign this document
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>has invited you to sign this document</Trans>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with(RecipientRole.APPROVER, () =>
|
||||||
|
document.teamId && !shouldUseTeamDetails ? (
|
||||||
|
<Trans>
|
||||||
|
on behalf of "{document.team?.name}" 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)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RejectDocumentDialog document={document} token={recipient.token} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentReadOnlyFields fields={completedFields} />
|
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||||
|
<Card
|
||||||
<AutoSign recipient={recipient} fields={fields} />
|
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||||
|
gradient
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
>
|
||||||
{fields.map((field) =>
|
<CardContent className="p-2">
|
||||||
match(field.type)
|
<LazyPDFViewer
|
||||||
.with(FieldType.SIGNATURE, () => (
|
key={documentData.id}
|
||||||
<SignatureField
|
documentData={documentData}
|
||||||
key={field.id}
|
document={document}
|
||||||
field={field}
|
password={documentMeta?.password}
|
||||||
recipient={recipient}
|
|
||||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
|
||||||
/>
|
/>
|
||||||
))
|
</CardContent>
|
||||||
.with(FieldType.INITIALS, () => (
|
</Card>
|
||||||
<InitialsField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||||
.with(FieldType.NAME, () => (
|
<SigningForm
|
||||||
<NameField key={field.id} field={field} recipient={recipient} />
|
document={document}
|
||||||
))
|
recipient={recipient}
|
||||||
.with(FieldType.DATE, () => (
|
fields={fields}
|
||||||
<DateField
|
redirectUrl={documentMeta?.redirectUrl}
|
||||||
key={field.id}
|
isRecipientsTurn={isRecipientsTurn}
|
||||||
field={field}
|
allRecipients={allRecipients}
|
||||||
recipient={recipient}
|
setSelectedSignerId={setSelectedSignerId}
|
||||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
/>
|
||||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
</div>
|
||||||
/>
|
</div>
|
||||||
))
|
|
||||||
.with(FieldType.EMAIL, () => (
|
<DocumentReadOnlyFields fields={completedFields} />
|
||||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
{recipient.role !== RecipientRole.ASSISTANT && (
|
||||||
.with(FieldType.TEXT, () => {
|
<AutoSign recipient={recipient} fields={fields} />
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return <TextField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
|
||||||
})
|
|
||||||
.with(FieldType.NUMBER, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return <NumberField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
|
||||||
})
|
|
||||||
.with(FieldType.RADIO, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return <RadioField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
|
||||||
})
|
|
||||||
.with(FieldType.CHECKBOX, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return <CheckboxField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
|
||||||
})
|
|
||||||
.with(FieldType.DROPDOWN, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return <DropdownField key={field.id} field={fieldWithMeta} recipient={recipient} />;
|
|
||||||
})
|
|
||||||
.otherwise(() => null),
|
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
|
||||||
</div>
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
|
{fields
|
||||||
|
.filter(
|
||||||
|
(field) =>
|
||||||
|
recipient.role !== RecipientRole.ASSISTANT ||
|
||||||
|
field.recipientId === selectedSigner?.id,
|
||||||
|
)
|
||||||
|
.map((field) =>
|
||||||
|
match(field.type)
|
||||||
|
.with(FieldType.SIGNATURE, () => <SignatureField key={field.id} field={field} />)
|
||||||
|
.with(FieldType.INITIALS, () => <InitialsField key={field.id} field={field} />)
|
||||||
|
.with(FieldType.NAME, () => <NameField key={field.id} field={field} />)
|
||||||
|
.with(FieldType.DATE, () => (
|
||||||
|
<DateField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||||
|
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with(FieldType.EMAIL, () => <EmailField key={field.id} field={field} />)
|
||||||
|
.with(FieldType.TEXT, () => {
|
||||||
|
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||||
|
...field,
|
||||||
|
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
|
||||||
|
};
|
||||||
|
return <TextField key={field.id} field={fieldWithMeta} />;
|
||||||
|
})
|
||||||
|
.with(FieldType.NUMBER, () => {
|
||||||
|
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||||
|
...field,
|
||||||
|
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
|
||||||
|
};
|
||||||
|
return <NumberField key={field.id} field={fieldWithMeta} />;
|
||||||
|
})
|
||||||
|
.with(FieldType.RADIO, () => {
|
||||||
|
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||||
|
...field,
|
||||||
|
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
|
||||||
|
};
|
||||||
|
return <RadioField key={field.id} field={fieldWithMeta} />;
|
||||||
|
})
|
||||||
|
.with(FieldType.CHECKBOX, () => {
|
||||||
|
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||||
|
...field,
|
||||||
|
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
|
||||||
|
};
|
||||||
|
return <CheckboxField key={field.id} field={fieldWithMeta} />;
|
||||||
|
})
|
||||||
|
.with(FieldType.DROPDOWN, () => {
|
||||||
|
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
||||||
|
...field,
|
||||||
|
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
|
||||||
|
};
|
||||||
|
return <DropdownField key={field.id} field={fieldWithMeta} />;
|
||||||
|
})
|
||||||
|
.otherwise(() => null),
|
||||||
|
)}
|
||||||
|
</ElementVisible>
|
||||||
|
</div>
|
||||||
|
</RecipientProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,16 +97,18 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<EmbedDirectTemplateClientPage
|
<RecipientProvider recipient={recipient}>
|
||||||
token={token}
|
<EmbedDirectTemplateClientPage
|
||||||
updatedAt={template.updatedAt}
|
token={token}
|
||||||
documentData={template.templateDocumentData}
|
updatedAt={template.updatedAt}
|
||||||
recipient={recipient}
|
documentData={template.templateDocumentData}
|
||||||
fields={fields}
|
recipient={recipient}
|
||||||
metadata={template.templateMeta}
|
fields={fields}
|
||||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
metadata={template.templateMeta}
|
||||||
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
||||||
/>
|
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
|
||||||
|
/>
|
||||||
|
</RecipientProvider>
|
||||||
</DocumentAuthProvider>
|
</DocumentAuthProvider>
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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,164 +225,234 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<RecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
||||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
|
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||||
|
|
||||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<LazyPDFViewer
|
<LazyPDFViewer
|
||||||
documentData={documentData}
|
documentData={documentData}
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Widget */}
|
{/* Widget */}
|
||||||
<div
|
<div
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||||
data-expanded={isExpanded || undefined}
|
data-expanded={isExpanded || undefined}
|
||||||
>
|
>
|
||||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<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">
|
||||||
<Trans>Sign document</Trans>
|
{isAssistantMode ? (
|
||||||
</h3>
|
<Trans>Assist with signing</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Sign document</Trans>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<LucideChevronDown
|
<LucideChevronDown
|
||||||
className="text-muted-foreground h-5 w-5"
|
className="text-muted-foreground h-5 w-5"
|
||||||
onClick={() => setIsExpanded(false)}
|
onClick={() => setIsExpanded(false)}
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LucideChevronUp
|
|
||||||
className="text-muted-foreground h-5 w-5"
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>Sign the document to complete the process.</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<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>
|
|
||||||
<Label htmlFor="full-name">
|
|
||||||
<Trans>Full Name</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="full-name"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
disabled={isNameLocked}
|
|
||||||
value={fullName}
|
|
||||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="email">
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
value={email}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="Signature">
|
|
||||||
<Trans>Signature</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<SignaturePad
|
|
||||||
className="h-44 w-full"
|
|
||||||
disabled={isThrottled || isSubmitting}
|
|
||||||
defaultValue={signature ?? undefined}
|
|
||||||
onChange={(value) => {
|
|
||||||
setSignature(value);
|
|
||||||
}}
|
|
||||||
onValidityChange={(isValid) => {
|
|
||||||
setSignatureValid(isValid);
|
|
||||||
}}
|
|
||||||
allowTypedSignature={Boolean(
|
|
||||||
metadata &&
|
|
||||||
'typedSignatureEnabled' in metadata &&
|
|
||||||
metadata.typedSignatureEnabled,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
) : (
|
||||||
</Card>
|
<LucideChevronUp
|
||||||
|
className="text-muted-foreground h-5 w-5"
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||||
<div className="text-destructive mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
<Trans>
|
{isAssistantMode ? (
|
||||||
Signature is too small. Please provide a more complete signature.
|
<Trans>Help complete the document for other signers.</Trans>
|
||||||
</Trans>
|
) : (
|
||||||
|
<Trans>Sign the document to complete the process.</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="border-border mb-8 mt-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<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">
|
||||||
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isAssistantMode && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="full-name">
|
||||||
|
<Trans>Full Name</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="full-name"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
disabled={isNameLocked}
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">
|
||||||
|
<Trans>Email</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
value={email}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="Signature">
|
||||||
|
<Trans>Signature</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<SignaturePad
|
||||||
|
className="h-44 w-full"
|
||||||
|
disabled={isThrottled || isSubmitting}
|
||||||
|
defaultValue={signature ?? undefined}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSignature(value);
|
||||||
|
}}
|
||||||
|
onValidityChange={(isValid) => {
|
||||||
|
setSignatureValid(isValid);
|
||||||
|
}}
|
||||||
|
allowTypedSignature={Boolean(
|
||||||
|
metadata &&
|
||||||
|
'typedSignatureEnabled' in metadata &&
|
||||||
|
metadata.typedSignatureEnabled,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{hasSignatureField && !signatureValid && (
|
||||||
|
<div className="text-destructive mt-2 text-sm">
|
||||||
|
<Trans>
|
||||||
|
Signature is too small. Please provide a more complete signature.
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
||||||
|
|
||||||
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
||||||
{pendingFields.length > 0 ? (
|
{pendingFields.length > 0 ? (
|
||||||
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
||||||
<Trans>Next</Trans>
|
<Trans>Next</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className="col-start-2"
|
className="col-start-2"
|
||||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
disabled={
|
||||||
loading={isSubmitting}
|
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
|
||||||
onClick={() => throttledOnCompleteClick()}
|
}
|
||||||
>
|
loading={isSubmitting}
|
||||||
<Trans>Complete</Trans>
|
onClick={() => throttledOnCompleteClick()}
|
||||||
</Button>
|
>
|
||||||
)}
|
<Trans>Complete</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||||
|
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||||
|
<Trans>Click to insert field</Trans>
|
||||||
|
</FieldToolTip>
|
||||||
|
)}
|
||||||
|
</ElementVisible>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
{!hidePoweredBy && (
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
<span>Powered by</span>
|
||||||
<Trans>Click to insert field</Trans>
|
<Logo className="ml-2 inline-block h-[14px]" />
|
||||||
</FieldToolTip>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ElementVisible>
|
|
||||||
|
|
||||||
{/* Fields */}
|
|
||||||
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
|
|
||||||
</div>
|
</div>
|
||||||
|
</RecipientProvider>
|
||||||
{!hidePoweredBy && (
|
|
||||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
|
||||||
<span>Powered by</span>
|
|
||||||
<Logo className="ml-2 inline-block h-[14px]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
48
apps/web/src/app/embed/waiting-for-turn.tsx
Normal file
48
apps/web/src/app/embed/waiting-for-turn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
type: {
|
||||||
|
not: FieldType.SIGNATURE,
|
||||||
|
},
|
||||||
|
recipient: {
|
||||||
|
signingStatus: {
|
||||||
|
not: SigningStatus.SIGNED,
|
||||||
|
},
|
||||||
|
signingOrder: {
|
||||||
|
gte: recipient.signingOrder ?? 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentId: recipient.documentId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
recipientId: recipient.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
signature: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return await prisma.field.findMany({
|
return await prisma.field.findMany({
|
||||||
where: {
|
where: {
|
||||||
recipient: {
|
recipientId: recipient.id,
|
||||||
token,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
signature: true,
|
signature: true,
|
||||||
|
|||||||
@ -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,20 +86,22 @@ export const removeSignedFieldWithToken = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.documentAuditLog.create({
|
if (recipient.role !== RecipientRole.ASSISTANT) {
|
||||||
data: createDocumentAuditLogData({
|
await tx.documentAuditLog.create({
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
data: createDocumentAuditLogData({
|
||||||
documentId: document.id,
|
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||||
user: {
|
documentId: document.id,
|
||||||
name: recipient?.name,
|
user: {
|
||||||
email: recipient?.email,
|
name: recipient.name,
|
||||||
},
|
email: recipient.email,
|
||||||
requestMetadata,
|
},
|
||||||
data: {
|
requestMetadata,
|
||||||
field: field.type,
|
data: {
|
||||||
fieldId: field.secondaryId,
|
field: field.type,
|
||||||
},
|
fieldId: field.secondaryId,
|
||||||
}),
|
},
|
||||||
});
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -9,5 +9,8 @@ export const getRecipientByToken = async ({ token }: GetRecipientByTokenOptions)
|
|||||||
where: {
|
where: {
|
||||||
token,
|
token,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
fields: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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`,
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "RecipientRole" ADD VALUE 'ASSISTANT';
|
||||||
@ -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';"])
|
||||||
|
|||||||
5
packages/prisma/types/recipient-with-fields.ts
Normal file
5
packages/prisma/types/recipient-with-fields.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type RecipientWithFields = Recipient & {
|
||||||
|
fields: Field[];
|
||||||
|
};
|
||||||
@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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) => ({
|
||||||
|
...signer,
|
||||||
|
role: idx === index ? role : signer.role,
|
||||||
|
signingOrder: !canRecipientBeModified(signer.nativeId) ? signer.signingOrder : idx + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
const preDrag = $sensorApi.current.tryGetLock(draggableId);
|
form.setValue('signers', updatedSigners);
|
||||||
|
|
||||||
if (!preDrag) {
|
if (role === RecipientRole.ASSISTANT && index === updatedSigners.length - 1) {
|
||||||
return;
|
toast({
|
||||||
|
title: _(msg`Warning: Assistant as last signer`),
|
||||||
|
description: _(
|
||||||
|
msg`Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist.`,
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const drag = preDrag.snapLift();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
// Move directly to the target index
|
|
||||||
if (fromIndex < toIndex) {
|
|
||||||
for (let i = fromIndex; i < toIndex; i++) {
|
|
||||||
drag.moveDown();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = fromIndex; i > toIndex; i--) {
|
|
||||||
drag.moveUp();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
drag.drop();
|
|
||||||
}, 500);
|
|
||||||
}, 0);
|
|
||||||
},
|
},
|
||||||
[signers],
|
[form, toast, canRecipientBeModified],
|
||||||
);
|
|
||||||
|
|
||||||
const updateSigningOrders = useCallback(
|
|
||||||
(newIndex: number, oldIndex: number) => {
|
|
||||||
const updatedSigners = form.getValues('signers').map((signer, index) => {
|
|
||||||
if (index === oldIndex) {
|
|
||||||
return { ...signer, signingOrder: newIndex + 1 };
|
|
||||||
} else if (index >= newIndex && index < oldIndex) {
|
|
||||||
return {
|
|
||||||
...signer,
|
|
||||||
signingOrder: !canRecipientBeModified(signer.nativeId)
|
|
||||||
? signer.signingOrder
|
|
||||||
: (signer.signingOrder ?? index + 1) + 1,
|
|
||||||
};
|
|
||||||
} else if (index <= newIndex && index > oldIndex) {
|
|
||||||
return {
|
|
||||||
...signer,
|
|
||||||
signingOrder: !canRecipientBeModified(signer.nativeId)
|
|
||||||
? signer.signingOrder
|
|
||||||
: Math.max(1, (signer.signingOrder ?? index + 1) - 1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return signer;
|
|
||||||
});
|
|
||||||
|
|
||||||
updatedSigners.forEach((signer, index) => {
|
|
||||||
form.setValue(`signers.${index}.signingOrder`, signer.signingOrder);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[form, canRecipientBeModified],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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" />,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user