feat: add empty emails for envelopes

This commit is contained in:
David Nguyen
2025-12-02 18:52:02 +11:00
parent 4774324e07
commit 16481bc34e
34 changed files with 977 additions and 193 deletions
@@ -19,6 +19,7 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
@@ -129,18 +130,41 @@ export const EnvelopeDistributeDialog = ({
const distributionMethod = watch('meta.distributionMethod');
const recipientsWithIndex = useMemo(
() =>
envelope.recipients.map((recipient, index) => ({
...recipient,
index,
})),
[envelope.recipients],
);
const recipientsMissingSignatureFields = useMemo(
() =>
envelope.recipients.filter(
recipientsWithIndex.filter(
(recipient) =>
recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
[envelope.recipients, envelope.fields],
[recipientsWithIndex, envelope.fields],
);
/**
* List of recipients who must have an email due to having auth enabled.
*/
const recipientsMissingRequiredEmail = useMemo(() => {
return recipientsWithIndex.filter((recipient) => {
const auth = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
return auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired;
});
}, [recipientsWithIndex, envelope.authOptions]);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -150,8 +174,12 @@ export const EnvelopeDistributeDialog = ({
return 'MISSING_RECIPIENTS';
}
if (recipientsMissingRequiredEmail.length > 0) {
return 'MISSING_REQUIRED_EMAIL';
}
return null;
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try {
@@ -444,7 +472,22 @@ export const EnvelopeDistributeDialog = ({
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>{recipient.email}</li>
<li key={recipient.id}>
{recipient.email || recipient.name || `Recipient ${recipient.index + 1}`}
</li>
))}
</ul>
</AlertDescription>
))
.with('MISSING_REQUIRED_EMAIL', () => (
<AlertDescription>
<Trans>The following recipients require an email address:</Trans>
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingRequiredEmail.map((recipient) => (
<li key={recipient.id}>
{recipient.email || recipient.name || `Recipient ${recipient.index + 1}`}
</li>
))}
</ul>
</AlertDescription>
@@ -21,6 +21,7 @@ import {
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -65,7 +66,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
@@ -349,7 +350,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="distributeDocument"
>
<Trans>Send document</Trans>
@@ -358,7 +359,7 @@ export function TemplateUseDialog({
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
The document will be immediately sent to recipients if this
@@ -378,7 +379,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
@@ -386,7 +387,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Create the document as pending and ready to sign.
@@ -432,7 +433,7 @@ export function TemplateUseDialog({
}}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="useCustomDocument"
>
<Trans>Upload custom document</Trans>
@@ -440,7 +441,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Upload a custom document to use instead of the template's default
@@ -470,19 +471,19 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
>
<div className="flex-shrink-0">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<FileTextIcon className="text-primary h-5 w-5" />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
</div>
</div>
<div className="min-w-0 flex-1">
<h4 className="text-foreground truncate text-sm font-medium">
<h4 className="truncate text-sm font-medium text-foreground">
{item.title}
</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<p className="mt-0.5 text-xs text-muted-foreground">
{field.value ? (
<div>
<Trans>
@@ -5,6 +5,7 @@ import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaLanguageSchema,
} from '@documenso/lib/types/document-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
// Define the schema for configuration
@@ -55,7 +56,7 @@ export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.exten
nativeId: z.number().optional(),
formId: z.string(),
name: z.string(),
email: z.union([z.string().length(0), z.string().email('Invalid email address')]),
email: ZRecipientEmailSchema,
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
@@ -57,7 +57,7 @@ export type DocumentSigningCompleteDialogProps = {
name: string;
email: string;
};
directTemplatePayload?: {
recipientPayload?: {
name: string;
email: string;
};
@@ -89,7 +89,7 @@ export const DocumentSigningCompleteDialog = ({
recipient,
disabled = false,
allowDictateNextSigner = false,
directTemplatePayload,
recipientPayload,
defaultNextSigner,
buttonSize = 'lg',
position,
@@ -113,11 +113,11 @@ export const DocumentSigningCompleteDialog = ({
},
});
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
const recipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: {
name: directTemplatePayload?.name ?? '',
email: directTemplatePayload?.email ?? '',
name: recipientPayload?.name ?? '',
email: recipientPayload?.email ?? '',
},
});
@@ -145,16 +145,16 @@ export const DocumentSigningCompleteDialog = ({
const onFormSubmit = async (data: TNextSignerFormSchema) => {
try {
let directRecipient: { name: string; email: string } | undefined;
let recipientOverridePayload: { name: string; email: string } | undefined;
if (directTemplatePayload && !directTemplatePayload.email) {
const isFormValid = await directRecipientForm.trigger();
if (recipientPayload && !recipientPayload.email) {
const isFormValid = await recipientForm.trigger();
if (!isFormValid) {
return;
}
directRecipient = directRecipientForm.getValues();
recipientOverridePayload = recipientForm.getValues();
}
// Check if 2FA is required
@@ -168,7 +168,7 @@ export const DocumentSigningCompleteDialog = ({
? { name: data.name, email: data.email }
: undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
} catch (error) {
const err = AppError.parseError(error);
@@ -222,7 +222,7 @@ export const DocumentSigningCompleteDialog = ({
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<div className="text-muted-foreground max-w-[50ch]">
<div className="max-w-[50ch] text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<span className="inline-flex flex-wrap">
@@ -250,19 +250,19 @@ export const DocumentSigningCompleteDialog = ({
</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 className="rounded-lg border border-border bg-muted/50 p-4 text-center">
<p className="text-sm font-medium text-muted-foreground">{documentTitle}</p>
</div>
{!showTwoFactorForm && (
<>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
{directTemplatePayload && !directTemplatePayload.email && (
<Form {...directRecipientForm}>
{recipientPayload && !recipientPayload.email && (
<Form {...recipientForm}>
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={directRecipientForm.control}
control={recipientForm.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
@@ -284,7 +284,7 @@ export const DocumentSigningCompleteDialog = ({
/>
<FormField
control={directRecipientForm.control}
control={recipientForm.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
@@ -8,7 +8,6 @@ import {
type SensorAPI,
} from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
@@ -26,6 +25,7 @@ import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
@@ -65,10 +65,7 @@ const ZEnvelopeRecipientsForm = z.object({
z.object({
formId: z.string().min(1),
id: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -201,12 +198,13 @@ export const EnvelopeEditorRecipientForm = () => {
keyName: 'nativeId',
});
const emptySigners = useCallback(
() => form.getValues('signers').filter((signer) => signer.email === ''),
[form],
const emptySignerIndex = watchedSigners.findIndex(
(signer) =>
!signer.name &&
!signer.email &&
envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
);
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
);
@@ -460,21 +458,7 @@ export const EnvelopeEditorRecipientForm = () => {
return;
}
const formValueSigners = formValues.signers || [];
// Remove the last signer if it's empty.
const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
if (i === formValueSigners.length - 1 && signer.email === '') {
return false;
}
return true;
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
...formValues,
signers: nonEmptyRecipients,
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
if (!validatedFormValues.success) {
return;
@@ -570,7 +554,7 @@ export const EnvelopeEditorRecipientForm = () => {
<CardContent>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="bg-accent/50 -mt-2 mb-2 space-y-4 rounded-md p-4">
<div className="-mt-2 mb-2 space-y-4 rounded-md bg-accent/50 p-4">
{organisation.organisationClaim.flags.cfr21 && (
<div className="flex flex-row items-center">
<Checkbox
@@ -618,9 +602,7 @@ export const EnvelopeEditorRecipientForm = () => {
});
}
}}
disabled={
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
@@ -634,7 +616,7 @@ export const EnvelopeEditorRecipientForm = () => {
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
@@ -679,7 +661,7 @@ export const EnvelopeEditorRecipientForm = () => {
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircleIcon className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
@@ -732,7 +714,7 @@ export const EnvelopeEditorRecipientForm = () => {
{...provided.draggableProps}
{...provided.dragHandleProps}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
'pointer-events-none rounded-md bg-widget-foreground pt-2':
snapshot.isDragging,
})}
>
@@ -806,7 +788,7 @@ export const EnvelopeEditorRecipientForm = () => {
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
@@ -41,6 +41,11 @@ export const EnvelopeRecipientSelector = ({
}: EnvelopeRecipientSelectorProps) => {
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients),
[recipients],
);
return (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
@@ -49,7 +54,7 @@ export const EnvelopeRecipientSelector = ({
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
@@ -59,16 +64,12 @@ export const EnvelopeRecipientSelector = ({
className,
)}
>
{selectedRecipient?.email && (
{selectedRecipient && (
<span className="flex-1 truncate text-left">
{selectedRecipient?.name} ({selectedRecipient?.email})
{getRecipientLabel(selectedRecipient)}
</span>
)}
{!selectedRecipient?.email && (
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
@@ -154,6 +155,11 @@ export const EnvelopeRecipientSelectorCommand = ({
[fields, recipients],
);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients),
[recipients],
);
return (
<Command
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
@@ -162,21 +168,21 @@ export const EnvelopeRecipientSelectorCommand = ({
<CommandInput placeholder={placeholder} />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<span className="inline-block px-4 text-muted-foreground">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
>
<Trans>No recipients with this role</Trans>
</div>
@@ -205,18 +211,12 @@ export const EnvelopeRecipientSelectorCommand = ({
}}
>
<span
className={cn('text-foreground/70 truncate', {
className={cn('truncate text-foreground/70', {
'text-foreground/80': recipient.id === selectedRecipient?.id,
'opacity-50': isRecipientDisabled(recipient.id),
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
{getRecipientLabel(recipient)}
</span>
<div className="ml-auto flex items-center justify-center">
@@ -234,7 +234,7 @@ export const EnvelopeRecipientSelectorCommand = ({
<Info className="z-50 ml-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
This document has already been sent to this recipient. You can no longer
edit this recipient.
@@ -250,3 +250,22 @@ export const EnvelopeRecipientSelectorCommand = ({
</Command>
);
};
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[]) => {
if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`;
}
if (recipient.name) {
return recipient.name;
}
if (recipient.email) {
return recipient.email;
}
// Since objects are basically pointers we can use `indexOf` rather than `findIndex`
const index = recipients.indexOf(recipient);
return `Recipient ${index + 1}`;
};
@@ -80,12 +80,14 @@ export const EnvelopeSignerCompleteDialog = () => {
const handleOnCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
recipientDetails?: { name: string; email: string },
) => {
try {
await completeDocument({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions,
recipientOverride: recipientDetails,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
});
@@ -205,9 +207,18 @@ export const EnvelopeSignerCompleteDialog = () => {
}
};
const directTemplatePayload = useMemo(() => {
const recipientPayload = useMemo(() => {
if (!isDirectTemplate) {
return;
return {
name:
recipient.name ||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
'',
email:
recipient.email ||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
'',
};
}
return {
@@ -219,7 +230,7 @@ export const EnvelopeSignerCompleteDialog = () => {
return (
<DocumentSigningCompleteDialog
isSubmitting={isPending}
directTemplatePayload={directTemplatePayload}
recipientPayload={recipientPayload}
onSignatureComplete={
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
}