mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { Field, Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@ -18,7 +18,9 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
@ -45,6 +47,7 @@ export type DocumentSigningCompleteDialogProps = {
|
||||
onSignatureComplete: (
|
||||
nextSigner?: { name: string; email: string },
|
||||
accessAuthOptions?: TRecipientAccessAuth,
|
||||
directRecipient?: { name: string; email: string },
|
||||
) => void | Promise<void>;
|
||||
recipient: Pick<Recipient, 'name' | 'email' | 'role' | 'token'>;
|
||||
disabled?: boolean;
|
||||
@ -53,6 +56,12 @@ export type DocumentSigningCompleteDialogProps = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
directTemplatePayload?: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
buttonSize?: 'sm' | 'lg';
|
||||
position?: 'start' | 'end' | 'center';
|
||||
};
|
||||
|
||||
const ZNextSignerFormSchema = z.object({
|
||||
@ -63,6 +72,13 @@ const ZNextSignerFormSchema = z.object({
|
||||
|
||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
||||
|
||||
const ZDirectRecipientFormSchema = z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email('Invalid email address'),
|
||||
});
|
||||
|
||||
type TDirectRecipientFormSchema = z.infer<typeof ZDirectRecipientFormSchema>;
|
||||
|
||||
export const DocumentSigningCompleteDialog = ({
|
||||
isSubmitting,
|
||||
documentTitle,
|
||||
@ -72,15 +88,19 @@ export const DocumentSigningCompleteDialog = ({
|
||||
recipient,
|
||||
disabled = false,
|
||||
allowDictateNextSigner = false,
|
||||
directTemplatePayload,
|
||||
defaultNextSigner,
|
||||
buttonSize = 'lg',
|
||||
position,
|
||||
}: DocumentSigningCompleteDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
||||
|
||||
const [showTwoFactorForm, setShowTwoFactorForm] = useState(false);
|
||||
const [twoFactorValidationError, setTwoFactorValidationError] = useState<string | null>(null);
|
||||
|
||||
const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext();
|
||||
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const form = useForm<TNextSignerFormSchema>({
|
||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||
@ -90,6 +110,14 @@ export const DocumentSigningCompleteDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
|
||||
resolver: zodResolver(ZDirectRecipientFormSchema),
|
||||
defaultValues: {
|
||||
name: directTemplatePayload?.name ?? '',
|
||||
email: directTemplatePayload?.email ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
||||
|
||||
const completionRequires2FA = useMemo(
|
||||
@ -109,12 +137,23 @@ export const DocumentSigningCompleteDialog = ({
|
||||
});
|
||||
}
|
||||
|
||||
setIsEditingNextSigner(false);
|
||||
setShowDialog(open);
|
||||
};
|
||||
|
||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
||||
try {
|
||||
let directRecipient: { name: string; email: string } | undefined;
|
||||
|
||||
if (directTemplatePayload && !directTemplatePayload.email) {
|
||||
const isFormValid = await directRecipientForm.trigger();
|
||||
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
directRecipient = directRecipientForm.getValues();
|
||||
}
|
||||
|
||||
// Check if 2FA is required
|
||||
if (completionRequires2FA && !data.accessAuthOptions) {
|
||||
setShowTwoFactorForm(true);
|
||||
@ -126,7 +165,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
? { name: data.name, email: data.email }
|
||||
: undefined;
|
||||
|
||||
await onSignatureComplete(nextSigner, data.accessAuthOptions);
|
||||
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
|
||||
} catch (error) {
|
||||
const err = AppError.parseError(error);
|
||||
|
||||
@ -152,21 +191,19 @@ export const DocumentSigningCompleteDialog = ({
|
||||
void form.handleSubmit(onFormSubmit)();
|
||||
};
|
||||
|
||||
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
||||
|
||||
return (
|
||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
size="lg"
|
||||
size={buttonSize}
|
||||
onClick={fieldsValidated}
|
||||
loading={isSubmitting}
|
||||
disabled={disabled}
|
||||
>
|
||||
{match({ isComplete, role: recipient.role })
|
||||
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
||||
.with({ isComplete: false }, () => <Trans>Next Field</Trans>)
|
||||
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
||||
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
||||
<Trans>Mark as viewed</Trans>
|
||||
@ -176,106 +213,98 @@ export const DocumentSigningCompleteDialog = ({
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogContent position={position}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete viewing the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete signing the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete approving the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<span className="inline-flex flex-wrap">
|
||||
<Trans>You are about to complete assisting the following document</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.CC, () => null)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
|
||||
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
|
||||
</div>
|
||||
|
||||
{!showTwoFactorForm && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||
<DialogTitle>
|
||||
<div className="text-foreground text-xl font-semibold">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
||||
.exhaustive()}
|
||||
<>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
||||
{directTemplatePayload && !directTemplatePayload.email && (
|
||||
<Form {...directRecipientForm}>
|
||||
<div className="mb-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={directRecipientForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Your Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} className="mt-2" placeholder={t`Enter your name`} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={directRecipientForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Your Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
className="mt-2"
|
||||
placeholder={t`Enter your email`}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground max-w-[50ch]">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete signing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete approving{' '}
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
"{documentTitle}"
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<span>
|
||||
<Trans>
|
||||
<span className="inline-flex flex-wrap">
|
||||
You are about to complete viewing "
|
||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||
{documentTitle}
|
||||
</span>
|
||||
".
|
||||
</span>
|
||||
<br /> Are you sure?
|
||||
</Trans>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allowDictateNextSigner && (
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
{!isEditingNextSigner && (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The next recipient to sign this document will be{' '}
|
||||
<span className="font-semibold">{form.watch('name')}</span> (
|
||||
<span className="font-semibold">{form.watch('email')}</span>).
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
||||
>
|
||||
<Trans>Update Recipient</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingNextSigner && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
{allowDictateNextSigner && defaultNextSigner && (
|
||||
<div className="mb-4 flex flex-col gap-4">
|
||||
{/* Todo: Envelopes - Should we say "The next recipient to sign this document will be"? */}
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@ -283,13 +312,13 @@ export const DocumentSigningCompleteDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
<Trans>Next Recipient Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="mt-2"
|
||||
placeholder="Enter the next signer's name"
|
||||
placeholder={t`Enter the next signer's name`}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -304,14 +333,14 @@ export const DocumentSigningCompleteDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Email</Trans>
|
||||
<Trans>Next Recipient Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
className="mt-2"
|
||||
placeholder="Enter the next signer's email"
|
||||
placeholder={t`Enter the next signer's email`}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@ -319,17 +348,14 @@ export const DocumentSigningCompleteDialog = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DocumentSigningDisclosure className="mt-4" />
|
||||
<DocumentSigningDisclosure />
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
variant="secondary"
|
||||
onClick={() => setShowDialog(false)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
@ -339,8 +365,7 @@ export const DocumentSigningCompleteDialog = ({
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!isComplete || !isNextSignerValid}
|
||||
disabled={!isComplete}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{match(recipient.role)
|
||||
@ -351,11 +376,11 @@ export const DocumentSigningCompleteDialog = ({
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
||||
.exhaustive()}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTwoFactorForm && (
|
||||
|
||||
Reference in New Issue
Block a user